`;
employeeDetailsContainer.appendChild(empDiv);
const payTypeSelect = empDiv.querySelector('.pbp-emp-pay-type');
const salaryFields = empDiv.querySelector('.pbp-emp-salary-fields');
const hourlyFields = empDiv.querySelector('.pbp-emp-hourly-fields');
payTypeSelect.addEventListener('change', function() {
salaryFields.style.display = this.value === 'Salary' ? 'block' : 'none';
hourlyFields.style.display = this.value === 'Hourly' ? 'block' : 'none';
});
payTypeSelect.dispatchEvent(new Event('change')); // Initialize
const useCustomsCheckbox = empDiv.querySelector('.pbp-emp-use-defaults');
const customContainer = empDiv.querySelector(`#pbp-custom-contributions-container-${employeeIdCounter}`);
const addCustomBtn = empDiv.querySelector('.pbp-add-custom-contribution');
useCustomsCheckbox.addEventListener('change', function() {
const showCustom = !this.checked;
customContainer.style.display = showCustom ? 'block' : 'none';
addCustomBtn.style.display = showCustom ? 'inline-block' : 'none';
});
empDiv.querySelector('.pbp-remove-employee').addEventListener('click', function() {
this.closest('.pbp-employee-item').remove();
checkEmptyState(employeeDetailsContainer);
});
addCustomBtn.addEventListener('click', function() {
addCustomContributionToEmployee(this.closest('.pbp-employee-item').dataset.employeeId);
});
updateAllCurrencyPrefixes();
checkEmptyState(employeeDetailsContainer);
}
function addCustomContributionToEmployee(empId, name = '', type = 'Percentage', value = '') {
const container = document.getElementById(`pbp-custom-contributions-container-${empId}`);
const customContributionId = Date.now(); // Unique enough for this purpose
const itemDiv = document.createElement('div');
itemDiv.classList.add('pbp-repeatable-item', 'pbp-custom-contribution-item');
itemDiv.innerHTML = `
`;
container.appendChild(itemDiv);
itemDiv.querySelector('.pbp-remove-button').addEventListener('click', function() {
this.parentElement.remove();
checkEmptyState(container);
});
itemDiv.querySelector('.pbp-cc-type').addEventListener('change', function(e) {
const valueInput = this.closest('.pbp-repeatable-item').querySelector('.pbp-cc-value');
const prefixSpan = this.closest('.pbp-repeatable-item').querySelector('.pbp-currency-prefix');
const suffixSpan = this.closest('.pbp-repeatable-item').querySelector('.pbp-input-group span:last-child');
if (e.target.value === 'Fixed') {
prefixSpan.textContent = currencySymbolInput.value || '$';
suffixSpan.textContent = '';
valueInput.classList.add('pbp-currency-input');
} else { // Percentage
prefixSpan.textContent = '';
suffixSpan.textContent = '%';
valueInput.classList.remove('pbp-currency-input');
}
});
itemDiv.querySelector('.pbp-cc-type').dispatchEvent(new Event('change'));
updateAllCurrencyPrefixes();
checkEmptyState(container);
}
// --- Calculation Logic ---
const WEEKS_IN_YEAR = 52;
const MONTHS_IN_YEAR = 12;
function getNumEmployeePayPeriodsInBudgetPeriod(employeePayFreq, budgetPeriod) {
let empPaysPerYear;
switch (employeePayFreq) {
case 'Weekly': empPaysPerYear = WEEKS_IN_YEAR; break;
case 'Bi-Weekly': empPaysPerYear = WEEKS_IN_YEAR / 2; break;
case 'Semi-Monthly': empPaysPerYear = MONTHS_IN_YEAR * 2; break;
case 'Monthly': empPaysPerYear = MONTHS_IN_YEAR; break;
default: empPaysPerYear = 0;
}
switch (budgetPeriod) {
case 'Monthly': return empPaysPerYear / MONTHS_IN_YEAR;
case 'Quarterly': return empPaysPerYear / 4;
case 'Annually': return empPaysPerYear;
default: return 0;
}
}
function getGrossPayForBudgetPeriod(empData, budgetPeriod) {
const numEmployees = empData.count;
let singleEmployeeGross = 0;
const weeksInBudgetPeriod = budgetPeriod === 'Monthly' ? WEEKS_IN_YEAR / MONTHS_IN_YEAR :
budgetPeriod === 'Quarterly' ? WEEKS_IN_YEAR / 4 : WEEKS_IN_YEAR;
if (empData.payType === 'Salary') {
const annualSalary = empData.annualSalary || 0;
singleEmployeeGross = budgetPeriod === 'Monthly' ? annualSalary / MONTHS_IN_YEAR :
budgetPeriod === 'Quarterly' ? annualSalary / 4 : annualSalary;
} else if (empData.payType === 'Hourly') {
const hourlyRate = empData.hourlyRate || 0;
const hoursWeek = empData.hoursWeek || 0;
singleEmployeeGross = hourlyRate * hoursWeek * weeksInBudgetPeriod;
}
return singleEmployeeGross * numEmployees;
}
function collectInputData() {
const data = {
businessName: document.getElementById('pbp-business-name').value,
currencySymbol: currencySymbolInput.value || '$',
budgetPeriod: budgetPeriodSelect.value,
defaultContributions: [],
employees: []
};
document.querySelectorAll('.pbp-default-contribution-item').forEach(item => {
data.defaultContributions.push({
name: item.querySelector('.pbp-dc-name').value.trim(),
type: item.querySelector('.pbp-dc-type').value,
value: parseFloat(item.querySelector('.pbp-dc-value').value) || 0
});
});
document.querySelectorAll('.pbp-employee-item').forEach(empItem => {
const empId = empItem.dataset.employeeId;
const employee = {
id: empId,
name: empItem.querySelector('.pbp-emp-name').value.trim(),
count: parseInt(empItem.querySelector('.pbp-emp-count').value) || 1,
payType: empItem.querySelector('.pbp-emp-pay-type').value,
annualSalary: parseFloat(empItem.querySelector('.pbp-emp-annual-salary').value) || 0,
hourlyRate: parseFloat(empItem.querySelector('.pbp-emp-hourly-rate').value) || 0,
hoursWeek: parseFloat(empItem.querySelector('.pbp-emp-hours-week').value) || 0,
payFrequency: empItem.querySelector('.pbp-emp-pay-freq').value,
useDefaults: empItem.querySelector('.pbp-emp-use-defaults').checked,
customContributions: []
};
if (!employee.useDefaults) {
empItem.querySelectorAll(`#pbp-custom-contributions-container-${empId} .pbp-custom-contribution-item`).forEach(ccItem => {
employee.customContributions.push({
name: ccItem.querySelector('.pbp-cc-name').value.trim(),
type: ccItem.querySelector('.pbp-cc-type').value,
value: parseFloat(ccItem.querySelector('.pbp-cc-value').value) || 0
});
});
}
data.employees.push(employee);
});
return data;
}
function generateSummary() {
const inputData = collectInputData();
const summaryOutput = document.getElementById('pbp-summary-output');
let html = '
';
const allContributionNames = new Set();
inputData.defaultContributions.forEach(dc => allContributionNames.add(dc.name));
inputData.employees.forEach(emp => {
if (!emp.useDefaults) emp.customContributions.forEach(cc => allContributionNames.add(cc.name));
});
const contributionNamesArray = Array.from(allContributionNames);
html += `| Employee/Group | # | Gross Pay (${inputData.budgetPeriod}) | `;
contributionNamesArray.forEach(name => html += `${name} | `);
html += `Total Contributions | Total Payroll Cost |
`;
let overallTotalGrossPay = 0;
let overallTotalContributions = 0;
let overallGrandTotalPayroll = 0;
const overallContributionTotals = {}; // For summary by contribution type
contributionNamesArray.forEach(name => overallContributionTotals[name] = 0);
inputData.employees.forEach(emp => {
const grossPayForPeriod = getGrossPayForBudgetPeriod(emp, inputData.budgetPeriod);
let employeeTotalContributions = 0;
const contributionsForThisEmployee = {}; // Store calculated amounts for columns
const activeContributions = emp.useDefaults ? inputData.defaultContributions : emp.customContributions;
activeContributions.forEach(contrib => {
if(!contrib.name) return; // Skip if contribution name is empty
let contribAmount = 0;
if (contrib.type === 'Percentage') {
contribAmount = (grossPayForPeriod / emp.count) * (contrib.value / 100); // Per single employee in group
} else { // Fixed Amount
const numPaysInBudget = getNumEmployeePayPeriodsInBudgetPeriod(emp.payFrequency, inputData.budgetPeriod);
contribAmount = contrib.value * numPaysInBudget; // Per single employee in group
}
contribAmount *= emp.count; // Total for the group
contributionsForThisEmployee[contrib.name] = (contributionsForThisEmployee[contrib.name] || 0) + contribAmount;
employeeTotalContributions += contribAmount;
overallContributionTotals[contrib.name] = (overallContributionTotals[contrib.name] || 0) + contribAmount;
});
const totalPayrollCostForEmployee = grossPayForPeriod + employeeTotalContributions;
overallTotalGrossPay += grossPayForPeriod;
overallTotalContributions += employeeTotalContributions;
overallGrandTotalPayroll += totalPayrollCostForEmployee;
html += `
| ${emp.name || `Employee/Group ${emp.id}`} |
${emp.count} |
${inputData.currencySymbol}${grossPayForPeriod.toFixed(2)} | `;
contributionNamesArray.forEach(name => {
html += `${inputData.currencySymbol}${(contributionsForThisEmployee[name] || 0).toFixed(2)} | `;
});
html += `${inputData.currencySymbol}${employeeTotalContributions.toFixed(2)} |
${inputData.currencySymbol}${totalPayrollCostForEmployee.toFixed(2)} |
`;
});
html += `
| Subtotals |
${inputData.currencySymbol}${overallTotalGrossPay.toFixed(2)} | `;
contributionNamesArray.forEach(name => {
html += `${inputData.currencySymbol}${(overallContributionTotals[name] || 0).toFixed(2)} | `;
});
html += ` ${inputData.currencySymbol}${overallTotalContributions.toFixed(2)} |
${inputData.currencySymbol}${overallGrandTotalPayroll.toFixed(2)} |
`;
html += `
`;
html += `
Overall Budget Summary (${inputData.budgetPeriod})
`;
html += `
| Total Gross Pay: | ${inputData.currencySymbol}${overallTotalGrossPay.toFixed(2)} |
`;
contributionNamesArray.forEach(name => {
if(overallContributionTotals[name] > 0) { // Only show if there's a value
html += `| Total - ${name}: | ${inputData.currencySymbol}${(overallContributionTotals[name] || 0).toFixed(2)} |
`;
}
});
html += `| Total Employer Contributions: | ${inputData.currencySymbol}${overallTotalContributions.toFixed(2)} |
| GRAND TOTAL PAYROLL BUDGET: | ${inputData.currencySymbol}${overallGrandTotalPayroll.toFixed(2)} |
`;
summaryOutput.innerHTML = html;
downloadPdfButton.disabled = inputData.employees.length === 0;
}
// PDF Download
downloadPdfButton.addEventListener('click', function () {
if (typeof jspdf === 'undefined' || typeof jspdf.jsPDF === 'undefined') {
alert('Error: jsPDF library is not loaded.');
return;
}
const { jsPDF } = jspdf;
const doc = new jsPDF('landscape'); // Landscape for wider tables
const inputData = collectInputData();
const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--pbp-primary-color').trim();
const secondaryColor = getComputedStyle(document.documentElement).getPropertyValue('--pbp-secondary-color').trim();
const textColor = getComputedStyle(document.documentElement).getPropertyValue('--pbp-text-color').trim();
const buttonTextColor = getComputedStyle(document.documentElement).getPropertyValue('--pbp-button-text-color').trim();
doc.setFontSize(18);
doc.setTextColor(primaryColor);
doc.text('Payroll Budget Plan', doc.internal.pageSize.getWidth() / 2, 20, { align: 'center' });
doc.setFontSize(12);
doc.setTextColor(textColor);
doc.text(`Business Name: ${inputData.businessName || 'N/A'}`, 14, 30);
doc.text(`Budget Period: ${inputData.budgetPeriod}`, 14, 37);
doc.text(`Currency: ${inputData.currencySymbol}`, 14, 44);
let lastY = 50;
// Default Contributions Table (if any)
if (inputData.defaultContributions.length > 0) {
doc.setFontSize(14);
doc.setTextColor(primaryColor);
doc.text('Default Employer Contributions:', 14, lastY);
lastY += 7;
const dcHeaders = [['Contribution Name', 'Type', 'Value']];
const dcBody = inputData.defaultContributions.map(dc => [
dc.name,
dc.type,
dc.type === 'Percentage' ? `${dc.value.toFixed(2)}%` : `${inputData.currencySymbol}${dc.value.toFixed(2)}`
]);
doc.autoTable({
startY: lastY, head: dcHeaders, body: dcBody,
theme: 'grid', headStyles: { fillColor: secondaryColor, textColor: buttonTextColor }
});
lastY = doc.lastAutoTable.finalY + 10;
}
doc.setFontSize(14);
doc.setTextColor(primaryColor);
doc.text('Payroll Details:', 14, lastY);
lastY += 7;
const contributionNamesArray = Array.from(new Set(
inputData.defaultContributions.map(dc => dc.name)
.concat(...inputData.employees.filter(e => !e.useDefaults).map(e => e.customContributions.map(cc => cc.name)))
.filter(name => name) // Filter out empty names
));
const headers = [['Employee/Group', '#', `Gross Pay (${inputData.budgetPeriod})`, ...contributionNamesArray, 'Total Contributions', 'Total Payroll Cost']];
const body = [];
let overallTotalGrossPay = 0;
let overallTotalContributions = 0;
let overallGrandTotalPayroll = 0;
const overallContributionTotals = {};
contributionNamesArray.forEach(name => overallContributionTotals[name] = 0);
inputData.employees.forEach(emp => {
const row = [];
row.push(emp.name || `Employee/Group ${emp.id}`);
row.push(emp.count);
const grossPayForPeriod = getGrossPayForBudgetPeriod(emp, inputData.budgetPeriod);
row.push(`${inputData.currencySymbol}${grossPayForPeriod.toFixed(2)}`);
overallTotalGrossPay += grossPayForPeriod;
let employeeTotalContributions = 0;
const contributionsForThisEmployee = {};
const activeContributions = emp.useDefaults ? inputData.defaultContributions : emp.customContributions;
activeContributions.forEach(contrib => {
if(!contrib.name) return;
let contribAmount = 0;
if (contrib.type === 'Percentage') {
contribAmount = (grossPayForPeriod / emp.count) * (contrib.value / 100);
} else {
const numPaysInBudget = getNumEmployeePayPeriodsInBudgetPeriod(emp.payFrequency, inputData.budgetPeriod);
contribAmount = contrib.value * numPaysInBudget;
}
contribAmount *= emp.count;
contributionsForThisEmployee[contrib.name] = (contributionsForThisEmployee[contrib.name] || 0) + contribAmount;
employeeTotalContributions += contribAmount;
overallContributionTotals[contrib.name] = (overallContributionTotals[contrib.name] || 0) + contribAmount;
});
contributionNamesArray.forEach(name => {
row.push(`${inputData.currencySymbol}${(contributionsForThisEmployee[name] || 0).toFixed(2)}`);
});
row.push(`${inputData.currencySymbol}${employeeTotalContributions.toFixed(2)}`);
overallTotalContributions += employeeTotalContributions;
const totalPayrollCostForEmployee = grossPayForPeriod + employeeTotalContributions;
row.push(`${inputData.currencySymbol}${totalPayrollCostForEmployee.toFixed(2)}`);
overallGrandTotalPayroll += totalPayrollCostForEmployee;
body.push(row);
});
// Add Subtotal Row to PDF body
const subtotalRow = ['Subtotals', inputData.employees.reduce((sum, e) => sum + e.count, 0) || '', `${inputData.currencySymbol}${overallTotalGrossPay.toFixed(2)}`];
contributionNamesArray.forEach(name => subtotalRow.push(`${inputData.currencySymbol}${(overallContributionTotals[name] || 0).toFixed(2)}`));
subtotalRow.push(`${inputData.currencySymbol}${overallTotalContributions.toFixed(2)}`);
subtotalRow.push(`${inputData.currencySymbol}${overallGrandTotalPayroll.toFixed(2)}`);
body.push(subtotalRow.map(val => ({ content: val, styles: { fontStyle: 'bold', fillColor: '#f0f0f0' } })));
doc.autoTable({
startY: lastY,
head: headers,
body: body,
theme: 'grid',
headStyles: { fillColor: secondaryColor, textColor: buttonTextColor, fontStyle: 'bold' },
columnStyles: { // Adjust column widths as needed
0: { cellWidth: 'auto' }, // Emp Name
1: { cellWidth: 15, halign: 'right' }, // #
2: { cellWidth: 30, halign: 'right' }, // Gross
},
didParseCell: function (data) { // Right align numeric columns
const numericHeaders = ['#', `Gross Pay (${inputData.budgetPeriod})`, ...contributionNamesArray, 'Total Contributions', 'Total Payroll Cost'];
if (data.row.index > -1 && numericHeaders.includes(data.column.dataKey) ) { // data.row.index > -1 for body cells
if (data.cell.raw && typeof data.cell.raw === 'string' && data.cell.raw.startsWith(inputData.currencySymbol)) {
data.cell.styles.halign = 'right';
} else if (!isNaN(parseFloat(data.cell.raw))) {
data.cell.styles.halign = 'right';
}
}
if (data.row.section === 'head' && typeof data.column.dataKey === 'number' && data.column.dataKey > 0) { // Align header text for numbers to right
data.cell.styles.halign = 'right';
}
}
});
lastY = doc.lastAutoTable.finalY + 10;
// Summary Section
doc.setFontSize(14);
doc.setTextColor(primaryColor);
doc.text(`Overall Budget Summary (${inputData.budgetPeriod}):`, 14, lastY);
lastY += 7;
const summaryBody = [
[{ content: 'Total Gross Pay:', styles: { fontStyle: 'bold'} }, { content: `${inputData.currencySymbol}${overallTotalGrossPay.toFixed(2)}`, styles: { halign: 'right'} }],
];
contributionNamesArray.forEach(name => {
if (overallContributionTotals[name] > 0) {
summaryBody.push([
{ content: `Total - ${name}:`, styles: { fontStyle: 'normal'} },
{ content: `${inputData.currencySymbol}${(overallContributionTotals[name] || 0).toFixed(2)}`, styles: { halign: 'right'} }
]);
}
});
summaryBody.push(
[{ content: 'Total Employer Contributions:', styles: { fontStyle: 'bold'} }, { content: `${inputData.currencySymbol}${overallTotalContributions.toFixed(2)}`, styles: { halign: 'right'} }],
[{ content: 'GRAND TOTAL PAYROLL BUDGET:', styles: { fontStyle: 'bold', fillColor: primaryColor, textColor: buttonTextColor} },
{ content: `${inputData.currencySymbol}${overallGrandTotalPayroll.toFixed(2)}`, styles: { fontStyle: 'bold', halign: 'right', fillColor: primaryColor, textColor: buttonTextColor} }]
);
doc.autoTable({
startY: lastY,
body: summaryBody,
theme: 'plain', // No grid for summary, or 'grid'
styles: { fontSize: 10 },
columnStyles: { 0: { cellWidth: 100 }, 1: { cellWidth: 50, halign: 'right'} },
});
doc.save(`Payroll_Budget_Plan_${inputData.businessName.replace(/\s+/g, '_') || 'Report'}.pdf`);
});
checkEmptyState(defaultContributionsContainer); // Initial check for default contributions
checkEmptyState(employeeDetailsContainer); // Initial check for employees
showTab(0); // Initialize first tab
updateAllCurrencyPrefixes(); // Set initial currency symbols
});