Files
tilbudgivern/frontend/src/components/LaborInput.js
T
alex fc40f65fec Add project data, quotes, and real project details for Kunda a/s
- Created test_project_data.json with project details including customer information, project description, and counts for geometry, labor, materials, and quotes.
- Added test_quote_data.json containing company information, project details, geometry, labor, materials, and calculation summary.
- Introduced test_real_project.json with comprehensive project data including labor, materials, and detailed calculations.
- Generated updated_real_project_quote.pdf reflecting the latest project quote details.
2025-09-17 15:13:52 +02:00

545 lines
20 KiB
JavaScript

import React, { useState, useEffect } from 'react';
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 [formData, setFormData] = useState({
carpenterCount: 2,
totalWorkHours: '',
totalAllocatedHours: '',
hourlyRate: 580, // Fixed rate
workBreakdown: defaultWorkBreakdown,
specialConditions: '',
notes: ''
});
const [historicalData, setHistoricalData] = useState(null);
const [showHistorical, setShowHistorical] = 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 existing labor data if available
useEffect(() => {
if (existingLabor) {
const workBreakdown = existingLabor.work_breakdown ? JSON.parse(existingLabor.work_breakdown) : defaultWorkBreakdown;
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 || ''
});
}
}, [existingLabor]);
// 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
}));
}
// Update total allocated hours when allocated breakdown changes
if (field === 'allocatedHours') {
const totalAllocatedFromBreakdown = updatedBreakdown.reduce((sum, item) => sum + (parseInt(item.allocatedHours) || 0), 0);
setFormData(prev => ({
...prev,
totalAllocatedHours: totalAllocatedFromBreakdown
}));
}
};
const calculateTotalCost = () => {
return (parseInt(formData.totalWorkHours) || 0) * formData.hourlyRate;
};
const applyHistoricalData = (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;
}
setFormData(prev => ({
...prev,
totalWorkHours: Math.round(scaledHours),
notes: `Baseret på lignende projekt: ${historicalProject.project_name} (${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 () => {
if (!geometry?.total_area || !geometry?.roof_type) {
setError('Geometri data mangler for AI estimering');
return;
}
setLoading(true);
setError('');
try {
// Først hent statistik for lignende projekter
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) {
// Brug statistik data til at foreslå timer
const suggestedHours = Math.round(
parseFloat(statsData.data.averageHoursPerM2) * parseFloat(geometry.total_area)
);
setFormData(prev => ({
...prev,
totalWorkHours: Math.max(suggestedHours, 8), // Minimum 8 timer
carpenterCount: statsData.data.averageCarpenters || 2
}));
alert(`🤖 AI Forslag baseret på ${statsData.data.projectCount} lignende projekter:\n` +
`• Anbefalede timer: ${Math.max(suggestedHours, 8)}\n` +
`• Gennemsnit timer/m²: ${statsData.data.averageHoursPerM2}\n` +
`• Anbefalede tømrere: ${statsData.data.averageCarpenters || 2}`);
} else {
// Fallback til API baseret estimering hvis ingen historisk data
const estimateResponse = await fetch(`${apiBaseUrl}/api/estimate-work-hours`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roof_type: geometry.roof_type,
total_area: parseFloat(geometry.total_area),
complexity_factor: parseFloat(geometry.complexity_factor || 1.0)
})
});
const estimateData = await estimateResponse.json();
if (estimateData.success) {
setFormData(prev => ({
...prev,
totalWorkHours: estimateData.data.estimatedHours
}));
alert(`🤖 AI Estimering (standard beregning):\n` +
`• Anbefalede timer: ${estimateData.data.estimatedHours}\n` +
`• Baseret på: ${geometry.total_area}${geometry.roof_type}`);
} else {
throw new Error('AI estimering fejlede');
}
}
} catch (error) {
console.error('Error getting AI work hour estimate:', error);
setError('Fejl ved AI timeestimering: ' + 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!');
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>
{/* 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} {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>
)}
<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={!geometry?.total_area || !geometry?.roof_type}
title="Få AI forslag til timer baseret på historiske data"
>
🤖 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">
<strong>{item.task}</strong>
<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">
<input
type="text"
value={item.description}
onChange={(e) => handleBreakdownChange(index, 'description', e.target.value)}
placeholder="Beskrivelse af arbejdet..."
className="description-input"
/>
</div>
</div>
))}
</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;