try {
const base64ImageData = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.onerror = (e) => reject(e);
reader.readAsDataURL(file);
});
// Prepare the prompt for the AI model to extract structured data from the image
const prompt = `
Analyze this image of a service estimate. Extract the following information and return it in a structured JSON format:
1. The name of the company that provided the estimate.
2. The cost of parts or materials.
3. The cost of labor.
4. The cost of any miscellaneous or other charges.
If a value is not explicitly listed (e.g., no miscellaneous charges), please set it to 0. All costs should be extracted as numbers.
`;
const payload = {
contents: [
{
role: "user",
parts: [
{ text: prompt },
{
inlineData: {
mimeType: file.type,
data: base64ImageData,
},
},
],
},
],
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: "OBJECT",
properties: {
companyName: { type: "STRING" },
partsCost: { type: "NUMBER" },
laborCost: { type: "NUMBER" },
miscCharges: { type: "NUMBER" },
},
propertyOrdering: ["companyName", "partsCost", "laborCost", "miscCharges"]
}
},
};
// Use exponential backoff for API calls
let retries = 0;
const maxRetries = 3;
const baseDelay = 1000;
const callApiWithBackoff = async () => {
try {
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${apiKey}`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
const jsonText = result?.candidates?.[0]?.content?.parts?.[0]?.text;
if (jsonText) {
const parsedData = JSON.parse(jsonText);
// Update the state with the extracted data
setEstimates((prevEstimates) =>
prevEstimates.map((estimate) =>
estimate.id === estimateId
? {
...estimate,
company: parsedData.companyName || '',
parts: parsedData.partsCost || 0,
labor: parsedData.laborCost || 0,
misc: parsedData.miscCharges || 0,
}
: estimate
)
);
} else {
throw new Error('No content returned from AI.');
}
} catch (e) {
if (retries < maxRetries) {
const delay = baseDelay * (2 ** retries) + Math.random() * 100;
retries++;
setTimeout(callApiWithBackoff, delay);
} else {
console.error("Failed to fetch from API after multiple retries:", e);
setError('Failed to process the image. Please try a different one or enter the data manually.');
}
} finally {
setIsUploading(false);
}
};
callApiWithBackoff();
} catch (e) {
console.error("Error during file processing:", e);
setError('An error occurred while reading the file.');
setIsUploading(false);
}
// Filter out empty estimates for a cleaner comparison
const validEstimates = estimates.filter((e) => e.company && (e.parts || e.labor || e.misc));
if (validEstimates.length === 0) {
setError('Please enter at least one estimate to analyze.');
setIsLoading(false);
return;
}
// Calculate totals for each valid estimate
const estimatesWithTotals = validEstimates.map((estimate) => ({
...estimate,
total: calculateTotal(estimate),
}));
let prompt;
let payload;
// Logic for single estimate assessment
if (validEstimates.length === 1) {
const singleEstimate = estimatesWithTotals[0];
prompt = `
Analyze this single company estimate for a project.
Break down the costs (parts, labor, misc) and identify any "unnecessary" or non-essential charges often included in the miscellaneous cost (e.g., administrative fees, travel fees, waste disposal).
Provide a brief explanation for why each is unnecessary.
Calculate a "DIY" (Do-It-Yourself) estimate. This should be the total cost of the estimate minus the labor cost and any identified unnecessary charges. Assume the parts cost is a fixed expense for the project.
Finally, provide a brief, easy-to-understand summary of the estimate and its cost breakdown.
Estimate:
- Company: ${singleEstimate.company}
Parts Cost: $${singleEstimate.parts}
Labor Cost: $${singleEstimate.labor}
Misc Charges: $${singleEstimate.misc}
Total Cost: $${singleEstimate.total.toFixed(2)}
`;
payload = {
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: "OBJECT",
properties: {
company: { type: "STRING" },
unnecessaryCharges: { type: "STRING" },
diyEstimate: { type: "NUMBER" },
diyExplanation: { type: "STRING" },
summary: { type: "STRING" }
},
propertyOrdering: ["company", "unnecessaryCharges", "diyEstimate", "diyExplanation", "summary"]
}
}
};
} else { // Logic for multiple estimate comparison
const cheapestEstimate = estimatesWithTotals.reduce((min, current) =>
(current.total < min.total ? current : min), estimatesWithTotals[0]
);
prompt = `
Analyze the following company estimates for a project.
Identify the cheapest estimate.
For each of the other estimates, itemize the differences in cost (parts, labor, misc) and total cost, both in absolute amounts and as a percentage compared to the cheapest estimate.
In addition to the comparison, please perform the following analysis for each estimate:
1. Identify any "unnecessary" or non-essential charges that are often included in the miscellaneous cost (e.g., administrative fees, travel fees, waste disposal). Provide a brief explanation for why each is unnecessary.
2. Calculate a "DIY" (Do-It-Yourself) estimate. This should be the total cost of the estimate minus the labor cost and any identified unnecessary charges. Assume the parts cost is a fixed expense for the project.
Finally, provide a brief, easy-to-understand summary comparing the estimates, highlighting the key differences and why one might be cheaper than the others.
Estimates:
${estimatesWithTotals.map(e => `
- Company: ${e.company}
Parts Cost: $${e.parts}
Labor Cost: $${e.labor}
Misc Charges: $${e.misc}
Total Cost: $${e.total.toFixed(2)}
`).join('')}
`;
payload = {
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: "OBJECT",
properties: {
cheapestCompany: { type: "STRING" },
comparisons: {
type: "ARRAY",
items: {
type: "OBJECT",
properties: {
company: { type: "STRING" },
totalDifference: { type: "NUMBER" },
totalDifferencePercentage: { type: "NUMBER" },
partsDifference: { type: "NUMBER" },
laborDifference: { type: "NUMBER" },
miscDifference: { type: "NUMBER" },
unnecessaryCharges: { type: "STRING" },
diyEstimate: { type: "NUMBER" },
diyExplanation: { type: "STRING" }
}
}
},
summary: { type: "STRING" }
},
propertyOrdering: ["cheapestCompany", "comparisons", "summary"]
}
}
};
}
// Use exponential backoff for API calls
let retries = 0;
const maxRetries = 3;
const baseDelay = 1000;
const callApiWithBackoff = async () => {
try {
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${apiKey}`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
const jsonText = result?.candidates?.[0]?.content?.parts?.[0]?.text;
if (jsonText) {
const parsedJson = JSON.parse(jsonText);
setComparisonResults({
estimates: estimatesWithTotals,
aiData: parsedJson,
isSingleEstimate: validEstimates.length === 1
});
} else {
throw new Error('No content returned from AI.');
}
} catch (e) {
if (retries < maxRetries) {
const delay = baseDelay * (2 ** retries) + Math.random() * 100;
retries++;
setTimeout(callApiWithBackoff, delay);
} else {
console.error("Failed to fetch from API after multiple retries:", e);
setError('Failed to get a response from the AI. Please try again.');
}
} finally {
setIsLoading(false);
}
};
callApiWithBackoff();
{/* Input section for estimates */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 mb-8">
{estimates.map((estimate, index) => (
<div key={estimate.id} className="bg-gray-100 p-5 rounded-xl border border-gray-200 shadow-sm">
<h2 className="text-xl font-semibold text-gray-700 mb-4">Estimate {index + 1}</h2>
<div className="space-y-4">
{/* New file upload section */}
<div className="flex flex-col items-center">
<label className="w-full relative flex items-center justify-center p-3 text-sm font-medium text-white bg-indigo-500 rounded-md shadow-sm hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer transition-colors duration-200">
{isUploading && <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>}
{isUploading ? 'Analyzing Image...' : 'Upload Estimate Image'}
<input
type="file"
className="sr-only"
accept="image/*,.pdf"
onChange={(e) => handleFileUpload(e, estimate.id)}
disabled={isUploading}
/>
</label>
<span className="text-xs text-gray-500 mt-2">.jpg, .png, .pdf files supported</span>
</div>
<div className="relative flex py-2 items-center">
<div className="flex-grow border-t border-gray-300"></div>
<span className="flex-shrink mx-4 text-gray-400">or</span>
<div className="flex-grow border-t border-gray-300"></div>
</div>
<div>
<label htmlFor={`company-${estimate.id}`} className="block text-sm font-medium text-gray-600">Company Name</label>
<input
type="text"
id={`company-${estimate.id}`}
value={estimate.company}
onChange={(e) => handleEstimateChange(estimate.id, 'company', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-colors duration-200 p-2"
placeholder="e.g., Green Tech Solutions"
/>
</div>
<div>
<label htmlFor={`parts-${estimate.id}`} className="block text-sm font-medium text-gray-600">Parts Cost ($)</label>
<input
type="number"
id={`parts-${estimate.id}`}
value={estimate.parts}
onChange={(e) => handleEstimateChange(estimate.id, 'parts', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-colors duration-200 p-2"
placeholder="0.00"
/>
</div>
<div>
<label htmlFor={`labor-${estimate.id}`} className="block text-sm font-medium text-gray-600">Labor Cost ($)</label>
<input
type="number"
id={`labor-${estimate.id}`}
value={estimate.labor}
onChange={(e) => handleEstimateChange(estimate.id, 'labor', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-colors duration-200 p-2"
placeholder="0.00"
/>
</div>
<div>
<label htmlFor={`misc-${estimate.id}`} className="block text-sm font-medium text-gray-600">Misc. Charges ($)</label>
<input
type="number"
id={`misc-${estimate.id}`}
value={estimate.misc}
onChange={(e) => handleEstimateChange(estimate.id, 'misc', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 transition-colors duration-200 p-2"
placeholder="0.00"
/>
</div>
<div className="flex justify-between items-center text-gray-700 mt-4">
<span className="font-semibold text-lg">Total:</span>
<span className="text-xl font-bold text-indigo-600">${calculateTotal(estimate).toFixed(2)}</span>
</div>
</div>
</div>
))}
{/* Add more column button */}
{estimates.length < 4 && (
<div className="flex items-center justify-center p-5">
<button
onClick={handleAddEstimate}
className="w-full h-full border-2 border-dashed border-gray-400 rounded-xl text-gray-500 hover:border-indigo-500 hover:text-indigo-500 transition-colors duration-200 flex flex-col items-center justify-center"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="mt-2 text-sm font-medium">Add another estimate</span>
</button>
</div>
)}
</div>
{/* Compare button and status messages */}
<div className="text-center">
<button
onClick={handleCompare}
disabled={isLoading || isUploading}
className="w-full sm:w-auto px-12 py-4 bg-indigo-600 text-white font-bold text-lg rounded-full shadow-lg hover:bg-indigo-700 focus:outline-none focus:ring-4 focus:ring-indigo-500 focus:ring-opacity-50 transition-transform transform hover:scale-105 disabled:bg-indigo-400 disabled:cursor-not-allowed"
>
{isLoading ? 'Analyzing...' : estimates.length > 1 ? 'Compare Estimates' : 'Analyze Estimate'}
</button>
{error && <p className="mt-4 text-red-500 font-medium">{error}</p>}
</div>
{/* Comparison results section */}
{comparisonResults && (
<div className="mt-12 bg-gray-50 p-6 rounded-2xl border-2 border-gray-200 shadow-inner">
<h2 className="text-2xl sm:text-3xl font-bold text-indigo-700 mb-6 text-center">
{comparisonResults.isSingleEstimate ? 'Quote Analysis' : 'Comparison Results'}
</h2>
{/* Chart visualization (only for multiple estimates) */}
{!comparisonResults.isSingleEstimate && (
<div className="mb-8 p-4 bg-white rounded-xl shadow-md">
<h3 className="text-xl font-semibold mb-4 text-gray-700">Cost Breakdown Chart</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="Parts" stroke="#8884d8" strokeWidth={2} activeDot={{ r: 8 }} />
<Line type="monotone" dataKey="Labor" stroke="#82ca9d" strokeWidth={2} />
<Line type="monotone" dataKey="Misc" stroke="#ffc658" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* AI-generated summary and detailed analysis */}
<div className="bg-white p-6 rounded-xl shadow-md space-y-6">
<h3 className="text-xl font-semibold text-gray-700">AI Analysis Summary</h3>
<p className="text-gray-800 leading-relaxed">
{comparisonResults.aiData.summary}
</p>
<hr className="border-gray-300 my-4" />
{/* Conditional rendering for itemized differences vs single analysis */}
{!comparisonResults.isSingleEstimate && (
<>
<h3 className="text-xl font-semibold text-gray-700">Itemized Differences</h3>
<div className="space-y-4">
{comparisonResults.estimates.map((estimate) => {
const cheapestEstimate = comparisonResults.estimates.find(
(e) => e.company === comparisonResults.aiData.cheapestCompany
);
if (estimate.company === cheapestEstimate.company) {
return (
<div key={estimate.id} className="p-4 bg-green-100 rounded-lg border border-green-300">
<p className="text-lg font-bold text-green-700">
{estimate.company} is the cheapest estimate with a total cost of ${estimate.total.toFixed(2)}.
</p>
</div>
);
}
const totalDifference = estimate.total - cheapestEstimate.total;
const totalPercentage = (totalDifference / cheapestEstimate.total) * 100;
const partsDifference = (parseFloat(estimate.parts) || 0) - (parseFloat(cheapestEstimate.parts) || 0);
const laborDifference = (parseFloat(estimate.labor) || 0) - (parseFloat(cheapestEstimate.labor) || 0);
const miscDifference = (parseFloat(estimate.misc) || 0) - (parseFloat(cheapestEstimate.misc) || 0);
return (
<div key={estimate.id} className="p-4 bg-yellow-100 rounded-lg border border-yellow-300">
<h4 className="font-bold text-lg text-yellow-800">{estimate.company}</h4>
<p className="mt-1 text-gray-800">
Total Cost: <span className="font-semibold text-yellow-900">${estimate.total.toFixed(2)}</span>
</p>
<p className="mt-1 text-gray-800">
It is <span className="font-semibold text-red-600">${totalDifference.toFixed(2)}</span> more expensive, which is a <span className="font-semibold text-red-600">{totalPercentage.toFixed(2)}%</span> difference compared to {cheapestEstimate.company}.
</p>
<ul className="mt-2 list-disc list-inside text-sm text-gray-700">
<li>Parts cost is ${partsDifference.toFixed(2)} {partsDifference >= 0 ? 'more' : 'less'}.</li>
<li>Labor cost is ${laborDifference.toFixed(2)} {laborDifference >= 0 ? 'more' : 'less'}.</li>
<li>Misc charges are ${miscDifference.toFixed(2)} {miscDifference >= 0 ? 'more' : 'less'}.</li>
</ul>
</div>
);
})}
</div>
<hr className="border-gray-300 my-4" />
</>
)}
<h3 className="text-xl font-semibold text-gray-700">DIY & Unnecessary Charges Analysis</h3>
<div className="space-y-4">
{comparisonResults.isSingleEstimate ? (
<div className="p-4 bg-white rounded-lg border border-gray-300 shadow-sm">
<h4 className="font-bold text-lg text-gray-800">{comparisonResults.estimates[0].company}</h4>
<p className="mt-1 text-gray-700">
<span className="font-semibold text-indigo-600">Estimated DIY Cost:</span> ${comparisonResults.aiData.diyEstimate.toFixed(2)}
<span className="text-sm text-gray-500 mt-1 block">{comparisonResults.aiData.diyExplanation}</span>
</p>
<p className="mt-2 text-gray-700">
<span className="font-semibold text-red-600">Unnecessary Charges:</span> {comparisonResults.aiData.unnecessaryCharges}
</p>
</div>
) : (
comparisonResults.aiData.comparisons.map((item, index) => (
<div key={index} className="p-4 bg-white rounded-lg border border-gray-300 shadow-sm">
<h4 className="font-bold text-lg text-gray-800">{item.company}</h4>
<p className="mt-1 text-gray-700">
<span className="font-semibold text-indigo-600">Estimated DIY Cost:</span> ${item.diyEstimate.toFixed(2)}
<span className="text-sm text-gray-500 mt-1 block">{item.diyExplanation}</span>
</p>
<p className="mt-2 text-gray-700">
<span className="font-semibold text-red-600">Unnecessary Charges:</span> {item.unnecessaryCharges}
</p>
</div>
))
)}
</div>
</div>
</div>
)}
</div>
</div>
Discover how artificial intelligence is transforming journalism and news consumption while preserving the core values of trustworthy reporting.