295 lines
13 KiB
JavaScript
295 lines
13 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
const dataScript = document.getElementById('financial-data');
|
|
if (!dataScript) return;
|
|
|
|
const appData = JSON.parse(dataScript.textContent);
|
|
const { projectId, months, metrics, initialFinancialData, baseData, overrides } = appData;
|
|
|
|
let state = {
|
|
isOverrideActive: false,
|
|
overrideMonth: null,
|
|
originalTableState: {},
|
|
currentFinancialData: JSON.parse(JSON.stringify(initialFinancialData)) // Deep copy
|
|
};
|
|
|
|
const table = document.getElementById('financials-table');
|
|
if (!table) return;
|
|
|
|
// UTILITY FUNCTIONS
|
|
const formatCurrency = (value) => `€${(value || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
const formatMargin = (value) => `${((value || 0) * 100).toFixed(2).replace('.', ',')}%`;
|
|
const parseLocaleNumber = (stringNumber) => {
|
|
if (typeof stringNumber !== 'string') return stringNumber;
|
|
// Remove thousands separators (.), then replace decimal comma with a period.
|
|
return Number(String(stringNumber).replace(/\./g, '').replace(',', '.'));
|
|
};
|
|
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
|
|
// MAIN LOGIC
|
|
function recalculateFinancials(overrideMonth, overrideValues) {
|
|
const newData = JSON.parse(JSON.stringify(state.currentFinancialData));
|
|
const overrideMonthIndex = months.indexOf(overrideMonth);
|
|
|
|
if (overrideMonthIndex === -1) {
|
|
console.error("Override month not found in months array!");
|
|
return state.currentFinancialData;
|
|
}
|
|
|
|
// Step 1: Apply the override values for the specific override month.
|
|
for (const key in overrideValues) {
|
|
overrideValues[key] = parseFloat(overrideValues[key] || 0);
|
|
}
|
|
|
|
newData['Opening Balance'][overrideMonth] = overrideValues['Opening Balance'];
|
|
newData.Billings[overrideMonth] = overrideValues.Billings;
|
|
newData.WIP[overrideMonth] = overrideValues.WIP;
|
|
newData.Expenses[overrideMonth] = overrideValues.Expenses;
|
|
newData.Cost[overrideMonth] = overrideValues.Cost;
|
|
newData.Payment[overrideMonth] = overrideValues.Payment;
|
|
|
|
let nsr = newData.WIP[overrideMonth] + newData.Billings[overrideMonth] - newData['Opening Balance'][overrideMonth] - newData.Expenses[overrideMonth];
|
|
newData.NSR[overrideMonth] = nsr;
|
|
let margin = (nsr !== 0) ? ((nsr - newData.Cost[overrideMonth]) / nsr) : 0;
|
|
newData.Margin[overrideMonth] = margin;
|
|
|
|
// Step 2: Recalculate all subsequent months
|
|
for (let i = overrideMonthIndex + 1; i < months.length; i++) {
|
|
const month = months[i];
|
|
const prevMonth = months[i - 1];
|
|
|
|
const prevCumulativeBilling = parseFloat(newData.Billings[prevMonth] || 0);
|
|
const prevCumulativeCost = parseFloat(newData.Cost[prevMonth] || 0);
|
|
const prevCumulativeExpenses = parseFloat(newData.Expenses[prevMonth] || 0);
|
|
const prevWIP = parseFloat(newData.WIP[prevMonth] || 0);
|
|
|
|
const monthlyCost = parseFloat(baseData.monthly_costs[month] || 0);
|
|
const monthlyBilling = parseFloat(baseData.monthly_billing[month] || 0);
|
|
const monthlyExpenses = parseFloat(baseData.monthly_expenses[month] || 0);
|
|
const monthlyWIPChange = parseFloat(baseData.monthly_wip[month] || 0);
|
|
|
|
const newCumulativeBilling = prevCumulativeBilling + monthlyBilling;
|
|
const newCumulativeCost = prevCumulativeCost + monthlyCost;
|
|
const newCumulativeExpenses = prevCumulativeExpenses + monthlyExpenses;
|
|
const newWIP = prevWIP + monthlyExpenses + monthlyWIPChange - monthlyBilling;
|
|
|
|
newData.Billings[month] = newCumulativeBilling;
|
|
newData.Cost[month] = newCumulativeCost;
|
|
newData.Expenses[month] = newCumulativeExpenses;
|
|
newData.WIP[month] = newWIP;
|
|
|
|
// THE FIX: Carry over the previous month's Net Service Revenue as the next month's Opening Balance.
|
|
newData['Opening Balance'][month] = newData.NSR[prevMonth] || 0;
|
|
newData.Payment[month] = 0;
|
|
|
|
nsr = newWIP + newCumulativeBilling - newData['Opening Balance'][month] - newCumulativeExpenses;
|
|
newData.NSR[month] = nsr;
|
|
margin = (nsr !== 0) ? ((nsr - newCumulativeCost) / nsr) : 0;
|
|
newData.Margin[month] = margin;
|
|
}
|
|
|
|
return newData;
|
|
}
|
|
|
|
function updateTable(newData) {
|
|
state.currentFinancialData = newData;
|
|
for (const metric of metrics) {
|
|
for (const month of months) {
|
|
const cell = table.querySelector(`td[data-month="${month}"][data-metric="${metric}"]`);
|
|
if (cell && cell.firstElementChild?.tagName !== 'INPUT') {
|
|
const value = newData[metric][month];
|
|
cell.textContent = metric === 'Margin' ? formatMargin(value) : formatCurrency(value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function recalculateForOverrideMonth(overrideMonth, overrideValues) {
|
|
const newData = JSON.parse(JSON.stringify(state.currentFinancialData)); // Deep copy
|
|
|
|
// Use the user's input values for the override month
|
|
newData['Opening Balance'][overrideMonth] = overrideValues['Opening Balance'];
|
|
newData.Billings[overrideMonth] = overrideValues.Billings;
|
|
newData.WIP[overrideMonth] = overrideValues.WIP;
|
|
newData.Expenses[overrideMonth] = overrideValues.Expenses;
|
|
newData.Cost[overrideMonth] = overrideValues.Cost;
|
|
newData.Payment[overrideMonth] = overrideValues.Payment;
|
|
|
|
// Recalculate dependent metrics for the override month
|
|
const nsr = newData.WIP[overrideMonth] + newData.Billings[overrideMonth] - newData['Opening Balance'][overrideMonth] - newData.Expenses[overrideMonth];
|
|
newData.NSR[overrideMonth] = nsr;
|
|
const margin = (nsr !== 0) ? ((nsr - newData.Cost[overrideMonth]) / nsr) : 0;
|
|
newData.Margin[overrideMonth] = margin;
|
|
|
|
return newData;
|
|
}
|
|
|
|
function handleInputChange() {
|
|
const overrideValues = {};
|
|
const editableMetrics = ['Opening Balance', 'Billings', 'WIP', 'Expenses', 'Cost', 'Payment'];
|
|
editableMetrics.forEach(metric => {
|
|
const input = table.querySelector(`td[data-month="${state.overrideMonth}"][data-metric="${metric}"] input`);
|
|
overrideValues[metric] = parseLocaleNumber(input.value) || 0;
|
|
});
|
|
|
|
const recalculatedData = recalculateForOverrideMonth(state.overrideMonth, overrideValues);
|
|
updateTable(recalculatedData);
|
|
}
|
|
|
|
function enterOverrideMode(month) {
|
|
if (state.isOverrideActive) return;
|
|
|
|
state.isOverrideActive = true;
|
|
state.overrideMonth = month;
|
|
|
|
const editableMetrics = ['Opening Balance', 'Billings', 'WIP', 'Expenses', 'Cost', 'Payment'];
|
|
|
|
metrics.forEach(metric => {
|
|
const cell = table.querySelector(`td[data-month="${month}"][data-metric="${metric}"]`);
|
|
if (!cell) return;
|
|
|
|
const originalValue = state.currentFinancialData[metric][month];
|
|
state.originalTableState[metric] = cell.innerHTML;
|
|
|
|
if (editableMetrics.includes(metric)) {
|
|
// Ensure originalValue is a number before formatting
|
|
const numericValue = (typeof originalValue === 'number') ? originalValue : 0;
|
|
// Format to locale string with comma decimal separator for the input value
|
|
const localeValue = numericValue.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
cell.innerHTML = `<input type="text" class="form-control form-control-sm" value="${localeValue}">`;
|
|
}
|
|
});
|
|
|
|
const debouncedRecalc = debounce(handleInputChange, 200);
|
|
table.querySelectorAll(`td[data-month="${month}"] input`).forEach(input => {
|
|
input.addEventListener('input', debouncedRecalc);
|
|
});
|
|
|
|
const buttonCell = table.querySelector(`th button[data-month="${month}"]`).parentElement;
|
|
state.originalTableState['button'] = buttonCell.innerHTML;
|
|
buttonCell.innerHTML = `
|
|
<div class="btn-group btn-group-sm mt-1" role="group">
|
|
<button type="button" class="btn btn-success confirm-override">Confirm</button>
|
|
<button type="button" class="btn btn-danger cancel-override">Cancel</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function confirmOverride() {
|
|
try {
|
|
const overrideValues = {};
|
|
const editableMetrics = ['Opening Balance', 'Billings', 'WIP', 'Expenses', 'Cost', 'Payment'];
|
|
editableMetrics.forEach(metric => {
|
|
const input = table.querySelector(`td[data-month="${state.overrideMonth}"][data-metric="${metric}"] input`);
|
|
if (!input) {
|
|
throw new Error(`Could not find input for metric: ${metric} in month ${state.overrideMonth}`);
|
|
}
|
|
overrideValues[metric] = parseLocaleNumber(input.value) || 0;
|
|
});
|
|
|
|
const finalData = recalculateFinancials(state.overrideMonth, overrideValues);
|
|
|
|
const monthsToSave = months.slice(months.indexOf(state.overrideMonth));
|
|
const dataToSave = {};
|
|
monthsToSave.forEach(month => {
|
|
dataToSave[month] = {};
|
|
metrics.forEach(metric => {
|
|
const rawValue = finalData[metric][month];
|
|
let cleanValue = typeof rawValue === 'string' ? parseLocaleNumber(rawValue) : rawValue;
|
|
|
|
if (!isFinite(cleanValue)) {
|
|
console.warn(`Invalid number for ${metric} in ${month}. Resetting to 0. Original:`, rawValue);
|
|
cleanValue = 0;
|
|
}
|
|
dataToSave[month][metric] = cleanValue;
|
|
});
|
|
});
|
|
|
|
const payload = {
|
|
project_id: projectId,
|
|
overrides: Object.entries(dataToSave).map(([month, values]) => ({
|
|
month: month,
|
|
opening_balance: values['Opening Balance'],
|
|
payment: values.Payment,
|
|
wip: values.WIP,
|
|
expenses: values.Expenses,
|
|
cost: values.Cost,
|
|
nsr: values.NSR,
|
|
margin: values.Margin,
|
|
is_confirmed: month === state.overrideMonth ? 1 : 0
|
|
}))
|
|
};
|
|
|
|
|
|
|
|
const response = await fetch('save_override.php?t=' + new Date().getTime(), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errorMsg = `HTTP error! status: ${response.status}`;
|
|
try {
|
|
const errorData = await response.json();
|
|
errorMsg = errorData.message || errorMsg;
|
|
} catch (e) {
|
|
// Not a JSON response
|
|
}
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
alert(result.message || 'Override confirmed successfully!');
|
|
window.location.reload();
|
|
} else {
|
|
throw new Error(result.message || 'Failed to save override.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error confirming override:', error);
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function exitOverrideMode() {
|
|
metrics.forEach(metric => {
|
|
const cell = table.querySelector(`td[data-month="${state.overrideMonth}"][data-metric="${metric}"]`);
|
|
if (cell) {
|
|
cell.innerHTML = state.originalTableState[metric];
|
|
}
|
|
});
|
|
|
|
const buttonCell = table.querySelector(`th .btn-group`).parentElement;
|
|
buttonCell.innerHTML = state.originalTableState['button'];
|
|
|
|
updateTable(initialFinancialData);
|
|
|
|
state.isOverrideActive = false;
|
|
state.overrideMonth = null;
|
|
state.originalTableState = {};
|
|
}
|
|
|
|
table.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('override-btn')) {
|
|
enterOverrideMode(e.target.dataset.month);
|
|
} else if (e.target.classList.contains('confirm-override')) {
|
|
confirmOverride();
|
|
} else if (e.target.classList.contains('cancel-override')) {
|
|
exitOverrideMode();
|
|
}
|
|
});
|
|
}); |