No professionals added yet.
`;
container.innerHTML = html;
return;
}
html += `
| Name |
Salary |
Billable Hours |
Profit Margin |
Actions |
`;
professionals.forEach(p => {
html += `
| ${p.name} |
$${p.salary.toLocaleString()} |
${p.hours.toLocaleString()} |
${p.margin}% |
|
`;
});
html += `
`;
container.innerHTML = html;
}
function handleAddUpdateProfessional() {
const id = parseInt(profIdInput.value);
const professional = {
name: profNameInput.value.trim(),
salary: parseFloat(profSalaryInput.value) || 0,
hours: parseInt(profHoursInput.value) || 0,
margin: parseFloat(profMarginInput.value) || 0
};
if (!professional.name || professional.salary <= 0 || professional.hours <= 0 || professional.margin < 0) {
alert("Please fill in all fields with valid data.");
return;
}
if (id) { // Update
const index = professionals.findIndex(p => p.id === id);
if (index !== -1) {
professionals[index] = { ...professionals[index], ...professional };
}
} else { // Add
professional.id = professionals.length > 0 ? Math.max(...professionals.map(p => p.id)) + 1 : 1;
professionals.push(professional);
}
resetForm();
renderProfessionalsTable();
calculateAllRates();
}
function editProfessional(id) {
const professional = professionals.find(p => p.id === id);
if (!professional) return;
profIdInput.value = professional.id;
profNameInput.value = professional.name;
profSalaryInput.value = professional.salary;
profHoursInput.value = professional.hours;
profMarginInput.value = professional.margin;
document.getElementById('professional-form-title').textContent = 'Edit Professional';
addUpdateBtn.textContent = 'Update Professional';
cancelEditBtn.classList.remove('hidden');
}
function deleteProfessional(id) {
professionals = professionals.filter(p => p.id !== id);
renderProfessionalsTable();
calculateAllRates();
}
function cancelEdit() {
resetForm();
}
function resetForm() {
profIdInput.value = '';
profNameInput.value = '';
profSalaryInput.value = '';
profHoursInput.value = '';
profMarginInput.value = '';
document.getElementById('professional-form-title').textContent = 'Add a Billable Professional';
addUpdateBtn.textContent = 'Add Professional';
cancelEditBtn.classList.add('hidden');
}
// --- CALCULATION LOGIC ---
function calculateAllRates() {
const overheadPerProfessional = professionals.length > 0 ? firmData.overhead / professionals.length : 0;
professionals.forEach(p => {
const totalCost = p.salary + overheadPerProfessional;
const costRate = p.hours > 0 ? totalCost / p.hours : 0;
const billingRate = (p.margin >= 100 || p.margin < 0) ? costRate : costRate / (1 - p.margin / 100);
p.costRate = costRate;
p.billingRate = billingRate;
p.overheadShare = overheadPerProfessional;
});
updateDashboard();
}
// --- UI & DASHBOARD UPDATE LOGIC ---
function updateUI() {
tabButtons.forEach((btn, index) => btn.classList.toggle('active', index === currentTabIndex));
tabPanels.forEach(panel => panel.classList.add('hidden'));
document.getElementById(`tab-panel-${tabs[currentTabIndex]}`).classList.remove('hidden');
prevBtn.disabled = currentTabIndex === 0;
prevBtn.classList.toggle('opacity-50', prevBtn.disabled);
nextBtn.disabled = currentTabIndex === tabs.length - 1;
nextBtn.classList.toggle('opacity-50', nextBtn.disabled);
prevBtn.style.visibility = (currentTabIndex === 0) ? 'hidden' : 'visible';
tabIndicator.textContent = `Step ${currentTabIndex + 1} of ${tabs.length}`;
}
function updateDashboard() {
const prompt = document.getElementById('dashboard-prompt');
const results = document.getElementById('dashboard-results');
if (professionals.length === 0) {
prompt.classList.remove('hidden');
results.classList.add('hidden');
downloadPdfBtn.disabled = true;
return;
}
prompt.classList.add('hidden');
results.classList.remove('hidden');
downloadPdfBtn.disabled = false;
renderResultsTable();
updateCharts();
// Default breakdown chart to first professional
if (professionals.length > 0) {
updateBreakdownChart(professionals[0].id);
}
}
function renderResultsTable() {
const container = document.getElementById('results-table-container');
let html = `
Detailed Rate Breakdown
| Name |
Cost Rate/hr |
Billing Rate/hr |
Profit/hr |
Annual Profit |
`;
professionals.forEach(p => {
const profitPerHour = p.billingRate - p.costRate;
const annualProfit = profitPerHour * p.hours;
html += `
| ${p.name} |
$${p.costRate.toFixed(2)} |
$${p.billingRate.toFixed(2)} |
$${profitPerHour.toFixed(2)} |
$${annualProfit.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})} |
`;
});
html += `
`;
container.innerHTML = html;
}
// --- CHARTING ---
function initializeCharts() {
const ratesCtx = document.getElementById('rates-chart');
const breakdownCtx = document.getElementById('breakdown-chart');
if(!ratesCtx || !breakdownCtx) return;
Chart.defaults.font.family = 'Inter';
ratesChart = new Chart(ratesCtx, {
type: 'bar',
data: { labels: [], datasets: [] },
options: {
responsive: true,
scales: { x: { grid: { display: false } }, y: { beginAtZero: true, ticks: { callback: (value) => '$' + value } } },
plugins: { legend: { position: 'top' } }
}
});
breakdownChart = new Chart(breakdownCtx, {
type: 'doughnut',
data: { labels: [], datasets: [] },
options: {
responsive: true,
plugins: {
legend: { position: 'top' },
tooltip: { callbacks: { label: (context) => {
let label = context.label || '';
if (label) label += ': ';
if (context.parsed !== null) label += new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(context.parsed);
return label;
}}}
}
}
});
}
function updateCharts() {
if (!ratesChart) return;
ratesChart.data.labels = professionals.map(p => p.name);
ratesChart.data.datasets = [
{
label: 'Cost Rate ($/hr)',
data: professionals.map(p => p.costRate.toFixed(2)),
backgroundColor: '#fbbf24', // amber-400
borderRadius: 4,
},
{
label: 'Billing Rate ($/hr)',
data: professionals.map(p => p.billingRate.toFixed(2)),
backgroundColor: '#10b981', // emerald-500
borderRadius: 4,
}
];
ratesChart.update();
}
function updateBreakdownChart(id) {
if (!breakdownChart) return;
const p = professionals.find(prof => prof.id === id);
if (!p) return;
const salaryPerHour = p.hours > 0 ? p.salary / p.hours : 0;
const overheadPerHour = p.hours > 0 ? p.overheadShare / p.hours : 0;
const profitPerHour = p.billingRate - p.costRate;
breakdownChart.data.labels = ['Salary Cost', 'Overhead Cost', 'Profit'];
breakdownChart.data.datasets = [{
label: 'Rate Breakdown',
data: [salaryPerHour, overheadPerHour, profitPerHour],
backgroundColor: ['#6366f1', '#a5b4fc', '#10b981'], // indigo, light-indigo, emerald
hoverOffset: 4
}];
breakdownChart.options.plugins.title = { display: true, text: `Rate Breakdown for ${p.name}` };
breakdownChart.update();
}
// --- PDF GENERATION ---
async function generatePdf() {
const { jsPDF } = window.jspdf;
const pdfContent = document.getElementById('pdf-content');
if (!pdfContent) return;
try {
const canvas = await html2canvas(pdfContent, { scale: 2, useCORS: true });
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' });
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const imgProps = pdf.getImageProperties(imgData);
const imgWidth = pdfWidth - 40;
const imgHeight = (imgProps.height * imgWidth) / imgProps.width;
let heightLeft = imgHeight;
let position = 20;
pdf.addImage(imgData, 'PNG', 20, position, imgWidth, imgHeight);
heightLeft -= (pdfHeight - 40);
while (heightLeft > 0) {
position = heightLeft - imgHeight + 20;
pdf.addPage();
pdf.addImage(imgData, 'PNG', 20, position, imgWidth, imgHeight);
heightLeft -= (pdfHeight - 40);
}
pdf.save('Legal_Billing_Rate_Report.pdf');
} catch (error) {
console.error('Error generating PDF:', error);
alert('An error occurred while generating the PDF.');
}
}
// --- Expose functions to global scope for inline onclicks ---
window.app = {
editProfessional,
deleteProfessional,
updateBreakdownChart
};
// --- START THE APP ---
initializeTool();
});