- 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.
545 lines
20 KiB
JavaScript
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} m² ${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}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>
|
|
)}
|
|
|
|
<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;
|