Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed329e88d7 | ||
|
|
110ea14346 | ||
|
|
dd81fb557e | ||
|
|
1d88e9ff8c | ||
|
|
c05b726631 | ||
|
|
8172a27692 | ||
|
|
ffe0142f5f | ||
|
|
893b3b9bfa | ||
|
|
982b6cfee6 | ||
|
|
e23abde359 | ||
|
|
09ebb5bdee | ||
|
|
3965ab4f7a | ||
|
|
7a2b03ad4b | ||
|
|
c76337f9a8 | ||
|
|
c80560ad92 | ||
|
|
2b9925f4fc | ||
|
|
47807955ad | ||
|
|
38ec03060f | ||
|
|
89109f468e | ||
|
|
3dfe3836b0 | ||
|
|
36ef6e0316 | ||
|
|
4a8e08c54b | ||
|
|
73cf51aa26 | ||
|
|
8be23f0e45 | ||
|
|
8cc108c2d6 | ||
|
|
00cb8e93bb | ||
|
|
e68686ffc2 | ||
|
|
a28074f4f8 |
121
assets/css/custom.css
Normal file
121
assets/css/custom.css
Normal file
@ -0,0 +1,121 @@
|
||||
|
||||
/* General Body Styling */
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #E0E0E0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Main wrapper for sidebar and content */
|
||||
.main-wrapper {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar Styling */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: #1E1E1E;
|
||||
padding: 1.5rem;
|
||||
border-right: 1px solid #333333;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: #A0A0A0;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
color: #E0E0E0;
|
||||
background-color: #282828;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #FFFFFF;
|
||||
background-color: #377DFF;
|
||||
}
|
||||
|
||||
/* Content Area */
|
||||
.content-wrapper {
|
||||
flex-grow: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Top Navbar */
|
||||
.top-navbar {
|
||||
background-color: #1E1E1E;
|
||||
border-bottom: 1px solid #333333;
|
||||
padding: 1rem 2rem;
|
||||
color: #E0E0E0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Card & Table Styling */
|
||||
.card {
|
||||
background-color: #1E1E1E;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: #E0E0E0;
|
||||
border-color: #333333;
|
||||
margin-bottom: 0; /* Remove default margin */
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
border-color: #333333;
|
||||
padding: 1rem; /* Comfortable row height */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background-color: #282828;
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
color: #E0E0E0;
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
/* Button Styling */
|
||||
.btn-primary {
|
||||
background-color: #377DFF;
|
||||
border-color: #377DFF;
|
||||
}
|
||||
.btn-primary:hover, .btn-primary:focus {
|
||||
background-color: #2968d6;
|
||||
border-color: #2968d6;
|
||||
}
|
||||
|
||||
/* Make disabled buttons look right */
|
||||
.btn.disabled, .btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Utility for spacious layout */
|
||||
.header-actions .btn {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Uniform Button Sizing */
|
||||
.page-header .header-actions .btn,
|
||||
.card .actions .btn,
|
||||
#override-btn {
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
}
|
||||
64
assets/js/billing.js
Normal file
64
assets/js/billing.js
Normal file
@ -0,0 +1,64 @@
|
||||
function initBillingPage(projectId) {
|
||||
const saveBtn = document.getElementById('save-billing');
|
||||
const billingAmountInputs = document.querySelectorAll('.billing-amount');
|
||||
const totalBillingCell = document.getElementById('total-billing');
|
||||
|
||||
function parseEuroNumber(str) {
|
||||
if (!str) return 0;
|
||||
// Remove thousand separators (.) and replace decimal comma (,) with a period (.)
|
||||
const cleanedStr = str.toString().replace(/\./g, '').replace(',', '.');
|
||||
return parseFloat(cleanedStr);
|
||||
}
|
||||
|
||||
function formatToEuro(num) {
|
||||
return num.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function updateTotalBilling() {
|
||||
let total = 0;
|
||||
billingAmountInputs.forEach(input => {
|
||||
total += parseEuroNumber(input.value) || 0;
|
||||
});
|
||||
totalBillingCell.textContent = formatToEuro(total);
|
||||
}
|
||||
|
||||
billingAmountInputs.forEach(input => {
|
||||
input.addEventListener('input', updateTotalBilling); // Use 'input' for better response
|
||||
});
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', function() {
|
||||
const billingData = [];
|
||||
billingAmountInputs.forEach(input => {
|
||||
billingData.push({
|
||||
month: input.dataset.month,
|
||||
amount: parseEuroNumber(input.value) // Send the parsed number
|
||||
});
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('projectId', projectId);
|
||||
formData.append('billingData', JSON.stringify(billingData));
|
||||
|
||||
fetch('save_billing.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('Billing data saved successfully!');
|
||||
} else {
|
||||
alert('Error saving billing data: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An unexpected error occurred.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial calculation
|
||||
updateTotalBilling();
|
||||
}
|
||||
30
assets/js/expenses.js
Normal file
30
assets/js/expenses.js
Normal file
@ -0,0 +1,30 @@
|
||||
function initExpensesPage(projectId) {
|
||||
document.querySelectorAll('.expenses-amount').forEach(input => {
|
||||
input.addEventListener('blur', function() {
|
||||
const month = this.dataset.month;
|
||||
const amount = this.value;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('projectId', projectId);
|
||||
formData.append('month', month);
|
||||
formData.append('amount', amount);
|
||||
|
||||
fetch('save_expenses.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Maybe show a small success indicator
|
||||
} else {
|
||||
alert('Error saving expenses amount: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An unexpected error occurred.');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
109
assets/js/forecasting.js
Normal file
109
assets/js/forecasting.js
Normal file
@ -0,0 +1,109 @@
|
||||
$(document).ready(function() {
|
||||
// Version selector
|
||||
$('#versionSelector').on('change', function() {
|
||||
const projectId = new URLSearchParams(window.location.search).get('projectId');
|
||||
const version = $(this).val();
|
||||
window.location.href = `forecasting.php?projectId=${projectId}&version=${version}`;
|
||||
});
|
||||
|
||||
// Roster search with Select2
|
||||
$('#rosterSearch').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: 'Search by name or SAP code...',
|
||||
allowClear: true
|
||||
});
|
||||
|
||||
// --- Inline editing for allocated days ---
|
||||
const table = document.querySelector('.table');
|
||||
let originalValue = null;
|
||||
|
||||
// Use event delegation on the table body
|
||||
const tableBody = table.querySelector('tbody');
|
||||
|
||||
tableBody.addEventListener('click', function(event) {
|
||||
const cell = event.target.closest('.editable');
|
||||
if (!cell) return; // Clicked outside an editable cell
|
||||
|
||||
// If the cell is already being edited, do nothing
|
||||
if (cell.isContentEditable) return;
|
||||
|
||||
originalValue = cell.textContent.trim();
|
||||
cell.setAttribute('contenteditable', 'true');
|
||||
|
||||
// Select all text in the cell
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(cell);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
cell.focus();
|
||||
});
|
||||
|
||||
tableBody.addEventListener('focusout', function(event) {
|
||||
const cell = event.target.closest('.editable');
|
||||
if (!cell) return;
|
||||
|
||||
cell.removeAttribute('contenteditable');
|
||||
const newValue = cell.textContent.trim();
|
||||
|
||||
// Only update if the value has changed and is a valid number
|
||||
if (newValue !== originalValue && !isNaN(newValue)) {
|
||||
updateAllocation(cell, newValue);
|
||||
} else if (newValue !== originalValue) {
|
||||
// Revert if the new value is not a number
|
||||
cell.textContent = originalValue;
|
||||
}
|
||||
});
|
||||
|
||||
tableBody.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter' && event.target.classList.contains('editable')) {
|
||||
event.preventDefault();
|
||||
event.target.blur(); // Triggers focusout to save
|
||||
} else if (event.key === 'Escape' && event.target.classList.contains('editable')) {
|
||||
event.target.textContent = originalValue; // Revert changes
|
||||
event.target.blur(); // Trigger focusout
|
||||
}
|
||||
});
|
||||
|
||||
function updateAllocation(cell, allocatedDays) {
|
||||
const rosterId = cell.dataset.rosterId;
|
||||
const month = cell.dataset.month;
|
||||
const forecastingId = cell.dataset.forecastingId;
|
||||
const projectId = new URLSearchParams(window.location.search).get('projectId');
|
||||
const originalContent = cell.innerHTML;
|
||||
|
||||
// Add loading indicator
|
||||
cell.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>`;
|
||||
|
||||
$.ajax({
|
||||
url: 'forecasting_actions.php',
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
action: 'update_allocation',
|
||||
forecastingId: forecastingId,
|
||||
rosterId: rosterId,
|
||||
month: month,
|
||||
allocatedDays: allocatedDays,
|
||||
projectId: projectId
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
// The server-side action reloads the page, so no need to update the cell manually.
|
||||
// If the reload were disabled, we'd do: cell.textContent = allocatedDays;
|
||||
window.location.reload(); // Ensure data consistency
|
||||
} else {
|
||||
console.error('Update failed:', response.error);
|
||||
alert('Error: ' + response.error);
|
||||
cell.innerHTML = originalContent; // Revert on failure
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('AJAX error:', error);
|
||||
alert('An unexpected error occurred. Please try again.');
|
||||
cell.innerHTML = originalContent; // Revert on AJAX error
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
74
assets/js/main.js
Normal file
74
assets/js/main.js
Normal file
@ -0,0 +1,74 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// New Resource Modal
|
||||
const newResourceBtn = document.getElementById('newResourceBtn');
|
||||
const newResourceModalEl = document.getElementById('newResourceModal');
|
||||
if (newResourceBtn && newResourceModalEl) {
|
||||
const newResourceModal = new bootstrap.Modal(newResourceModalEl);
|
||||
newResourceBtn.addEventListener('click', function () {
|
||||
newResourceModal.show();
|
||||
});
|
||||
}
|
||||
|
||||
// Edit Resource Modal
|
||||
const editResourceModalEl = document.getElementById('editResourceModal');
|
||||
if (editResourceModalEl) {
|
||||
const editResourceModal = new bootstrap.Modal(editResourceModalEl);
|
||||
const editButtons = document.querySelectorAll('.edit-btn');
|
||||
|
||||
editButtons.forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const data = JSON.parse(this.getAttribute('data-row'));
|
||||
|
||||
document.getElementById('edit-id').value = data.id;
|
||||
document.getElementById('edit-sapCode').value = data.sapCode;
|
||||
document.getElementById('edit-fullNameEn').value = data.fullNameEn;
|
||||
document.getElementById('edit-legalEntity').value = data.legalEntity;
|
||||
document.getElementById('edit-functionBusinessUnit').value = data.functionBusinessUnit;
|
||||
document.getElementById('edit-costCenterCode').value = data.costCenterCode;
|
||||
document.getElementById('edit-level').value = data.level;
|
||||
document.getElementById('edit-newAmendedSalary').value = data.newAmendedSalary;
|
||||
document.getElementById('edit-employerContributions').value = data.employerContributions;
|
||||
document.getElementById('edit-cars').value = data.cars;
|
||||
document.getElementById('edit-ticketRestaurant').value = data.ticketRestaurant;
|
||||
document.getElementById('edit-metlife').value = data.metlife;
|
||||
document.getElementById('edit-topusPerMonth').value = data.topusPerMonth;
|
||||
document.getElementById('edit-grossRevenue').value = data.grossRevenue;
|
||||
|
||||
editResourceModal.show();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// View Resource Modal
|
||||
const viewResourceModalEl = document.getElementById('viewResourceModal');
|
||||
if (viewResourceModalEl) {
|
||||
const viewResourceModal = new bootstrap.Modal(viewResourceModalEl);
|
||||
const viewButtons = document.querySelectorAll('.view-btn');
|
||||
|
||||
viewButtons.forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const data = JSON.parse(this.getAttribute('data-row'));
|
||||
|
||||
document.getElementById('view-sapCode').value = data.sapCode;
|
||||
document.getElementById('view-fullNameEn').value = data.fullNameEn;
|
||||
document.getElementById('view-legalEntity').value = data.legalEntity;
|
||||
document.getElementById('view-functionBusinessUnit').value = data.functionBusinessUnit;
|
||||
document.getElementById('view-costCenterCode').value = data.costCenterCode;
|
||||
document.getElementById('view-level').value = data.level;
|
||||
document.getElementById('view-newAmendedSalary').value = '€' + parseFloat(data.newAmendedSalary).toFixed(2);
|
||||
document.getElementById('view-employerContributions').value = '€' + parseFloat(data.employerContributions).toFixed(2);
|
||||
document.getElementById('view-cars').value = '€' + parseFloat(data.cars).toFixed(2);
|
||||
document.getElementById('view-ticketRestaurant').value = '€' + parseFloat(data.ticketRestaurant).toFixed(2);
|
||||
document.getElementById('view-metlife').value = '€' + parseFloat(data.metlife).toFixed(2);
|
||||
document.getElementById('view-topusPerMonth').value = '€' + parseFloat(data.topusPerMonth).toFixed(2);
|
||||
document.getElementById('view-totalSalaryCostWithLabor').value = '€' + parseFloat(data.totalSalaryCostWithLabor).toFixed(2);
|
||||
document.getElementById('view-totalMonthlyCost').value = '€' + parseFloat(data.totalMonthlyCost).toFixed(2);
|
||||
document.getElementById('view-totalAnnualCost').value = '€' + parseFloat(data.totalAnnualCost).toFixed(2);
|
||||
document.getElementById('view-grossRevenue').value = '€' + parseFloat(data.grossRevenue).toFixed(2);
|
||||
document.getElementById('view-dailyCost').value = '€' + (parseFloat(data.totalMonthlyCost) / 20).toFixed(2);
|
||||
|
||||
viewResourceModal.show();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
86
assets/js/project_details.js
Normal file
86
assets/js/project_details.js
Normal file
@ -0,0 +1,86 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const overrideBtns = document.querySelectorAll('.btn-override');
|
||||
|
||||
overrideBtns.forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
const projectId = this.dataset.projectId;
|
||||
const month = this.dataset.month;
|
||||
|
||||
if (this.textContent.trim() === 'Override') {
|
||||
this.textContent = 'Save';
|
||||
this.classList.remove('btn-outline-primary');
|
||||
this.classList.add('btn-success');
|
||||
|
||||
makeEditable('WIP', month);
|
||||
makeEditable('Opening Balance', month);
|
||||
makeEditable('Billings', month);
|
||||
makeEditable('Expenses', month);
|
||||
makeEditable('Cost', month);
|
||||
|
||||
} else if (this.textContent.trim() === 'Save') {
|
||||
const formData = new FormData();
|
||||
formData.append('projectId', projectId);
|
||||
formData.append('month', month + '-01');
|
||||
formData.append('wip', document.getElementById(`wip-${month}-input`).value);
|
||||
formData.append('openingBalance', document.getElementById(`opening-balance-${month}-input`).value);
|
||||
formData.append('billings', document.getElementById(`billings-${month}-input`).value);
|
||||
formData.append('expenses', document.getElementById(`expenses-${month}-input`).value);
|
||||
formData.append('cost', document.getElementById(`cost-${month}-input`).value);
|
||||
|
||||
fetch('save_override.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
// Get response text for error message
|
||||
return response.text().then(text => {
|
||||
throw new Error(`HTTP error! status: ${response.status}, response: ${text}`);
|
||||
});
|
||||
}
|
||||
return response.json(); // Directly parse JSON
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error saving override: ' + (data.message || 'Unknown error.'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`An unexpected error occurred: ${error.message}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function makeEditable(metric, month) {
|
||||
const cellId = `${metric.toLowerCase().replace(/\s/g, '-')}-${month}`;
|
||||
const cell = document.getElementById(cellId);
|
||||
if (cell) {
|
||||
let value = cell.textContent.replace(/[€%]/g, '').replace(/,/g, '').trim();
|
||||
let numericValue = parseFloat(value);
|
||||
if (isNaN(numericValue)) {
|
||||
numericValue = 0;
|
||||
}
|
||||
if (metric === 'Margin') {
|
||||
numericValue = numericValue / 100;
|
||||
}
|
||||
cell.innerHTML = `<input type="number" step="0.01" id="${cellId}-input" class="form-control" value="${numericValue.toFixed(2)}">`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateMetricCell(metric, month, value) {
|
||||
const cellId = `${metric.toLowerCase().replace(/\s/g, '-')}-${month}`;
|
||||
const cell = document.getElementById(cellId);
|
||||
if (cell) {
|
||||
const numericValue = value === '' ? 0 : parseFloat(value);
|
||||
if (metric === 'Margin') {
|
||||
cell.innerHTML = `${(numericValue * 100).toFixed(2)}%`;
|
||||
} else {
|
||||
cell.innerHTML = `€${numericValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
35
assets/js/projects.js
Normal file
35
assets/js/projects.js
Normal file
@ -0,0 +1,35 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// New Project Modal
|
||||
const newProjectBtn = document.getElementById('newProjectBtn');
|
||||
const newProjectModalEl = document.getElementById('newProjectModal');
|
||||
if (newProjectBtn && newProjectModalEl) {
|
||||
const newProjectModal = new bootstrap.Modal(newProjectModalEl);
|
||||
newProjectBtn.addEventListener('click', function () {
|
||||
newProjectModal.show();
|
||||
});
|
||||
}
|
||||
|
||||
// Edit Project Modal
|
||||
const editProjectModalEl = document.getElementById('editProjectModal');
|
||||
if (editProjectModalEl) {
|
||||
const editProjectModal = new bootstrap.Modal(editProjectModalEl);
|
||||
const editButtons = document.querySelectorAll('.edit-btn');
|
||||
|
||||
editButtons.forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const data = JSON.parse(this.getAttribute('data-row'));
|
||||
|
||||
document.getElementById('edit-id').value = data.id;
|
||||
document.getElementById('edit-name').value = data.name;
|
||||
document.getElementById('edit-wbs').value = data.wbs;
|
||||
document.getElementById('edit-startDate').value = data.startDate;
|
||||
document.getElementById('edit-endDate').value = data.endDate;
|
||||
document.getElementById('edit-budget').value = data.budget;
|
||||
document.getElementById('edit-recoverability').value = data.recoverability;
|
||||
document.getElementById('edit-targetMargin').value = data.targetMargin;
|
||||
|
||||
editProjectModal.show();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
119
billing.php
Normal file
119
billing.php
Normal file
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$projectId = $_GET['id'] ?? null;
|
||||
if (!$projectId) {
|
||||
header("Location: projects.php");
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("SELECT * FROM projects WHERE id = :id");
|
||||
$stmt->execute([':id' => $projectId]);
|
||||
$project = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$project) {
|
||||
header("Location: projects.php");
|
||||
exit();
|
||||
}
|
||||
|
||||
// Initialize billing records if they don't exist
|
||||
$startDate = new DateTime($project['startDate']);
|
||||
$endDate = new DateTime($project['endDate']);
|
||||
$currentMonth = clone $startDate;
|
||||
|
||||
while ($currentMonth <= $endDate) {
|
||||
$monthStr = $currentMonth->format('Y-m-01');
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM billingMonthly WHERE projectId = :projectId AND month = :month");
|
||||
$stmt->execute([':projectId' => $projectId, ':month' => $monthStr]);
|
||||
$count = $stmt->fetchColumn();
|
||||
|
||||
if ($count == 0) {
|
||||
$insertStmt = $pdo->prepare("INSERT INTO billingMonthly (projectId, month, amount) VALUES (:projectId, :month, 0)");
|
||||
$insertStmt->execute([':projectId' => $projectId, ':month' => $monthStr]);
|
||||
}
|
||||
|
||||
$currentMonth->modify('+1 month');
|
||||
}
|
||||
|
||||
// Fetch billing data
|
||||
$stmt = $pdo->prepare("SELECT * FROM billingMonthly WHERE projectId = :projectId ORDER BY month");
|
||||
$stmt->execute([':projectId' => $projectId]);
|
||||
$billingData = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$db_error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Billing - <?php echo htmlspecialchars($project['name']); ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-wrapper">
|
||||
<main class="content-wrapper">
|
||||
<div class="page-header">
|
||||
<h1 class="h2">Billing - <?php echo htmlspecialchars($project['name']); ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="project_details.php?id=<?php echo $projectId; ?>" class="btn btn-secondary">Return</a>
|
||||
<button id="save-billing" class="btn btn-primary">Save</button>
|
||||
<a href="export_billing.php?id=<?php echo $projectId; ?>" class="btn btn-secondary">Export to Excel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (isset($db_error)): ?>
|
||||
<div class="alert alert-danger"><?php echo htmlspecialchars($db_error); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 150px;">Billing</th>
|
||||
<?php
|
||||
$currentMonth = clone $startDate;
|
||||
while ($currentMonth <= $endDate) {
|
||||
echo '<th>' . $currentMonth->format('M Y') . '</th>';
|
||||
$currentMonth->modify('+1 month');
|
||||
}
|
||||
?>
|
||||
<th style="width: 150px;">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Billing</td>
|
||||
<?php
|
||||
$totalBilling = 0;
|
||||
foreach ($billingData as $billingMonth) {
|
||||
$totalBilling += $billingMonth['amount'];
|
||||
echo '<td><input type="text" class="form-control billing-amount text-end" data-month="' . $billingMonth['month'] . '" value="' . htmlspecialchars(number_format($billingMonth['amount'], 2, ',', '.')) . '"></td>';
|
||||
}
|
||||
?>
|
||||
<td id="total-billing" class="fw-bold text-end"><?php echo number_format($totalBilling, 2, ',', '.'); ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="assets/js/billing.js?v=<?php echo time(); ?>"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const projectId = <?php echo json_encode($projectId); ?>;
|
||||
initBillingPage(projectId);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
21
db/migrations/001_create_roster_table.sql
Normal file
21
db/migrations/001_create_roster_table.sql
Normal file
@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS roster (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
sapCode VARCHAR(255) NOT NULL,
|
||||
fullNameEn VARCHAR(255) NOT NULL,
|
||||
legalEntity VARCHAR(255),
|
||||
functionBusinessUnit VARCHAR(255),
|
||||
costCenterCode VARCHAR(255),
|
||||
level VARCHAR(255),
|
||||
newAmendedSalary DECIMAL(12, 2) DEFAULT 0.00,
|
||||
employerContributions DECIMAL(12, 2) DEFAULT 0.00,
|
||||
cars DECIMAL(12, 2) DEFAULT 0.00,
|
||||
ticketRestaurant DECIMAL(12, 2) DEFAULT 0.00,
|
||||
metlife DECIMAL(12, 2) DEFAULT 0.00,
|
||||
topusPerMonth DECIMAL(12, 2) DEFAULT 0.00,
|
||||
totalSalaryCostWithLabor DECIMAL(12, 2) DEFAULT 0.00,
|
||||
totalMonthlyCost DECIMAL(12, 2) DEFAULT 0.00,
|
||||
totalAnnualCost DECIMAL(14, 2) DEFAULT 0.00,
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE(sapCode)
|
||||
);
|
||||
13
db/migrations/002_create_projects_table.sql
Normal file
13
db/migrations/002_create_projects_table.sql
Normal file
@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS `projects` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`wbs` varchar(255) DEFAULT NULL,
|
||||
`startDate` date DEFAULT NULL,
|
||||
`endDate` date DEFAULT NULL,
|
||||
`budget` decimal(15,2) DEFAULT 0.00,
|
||||
`recoverability` decimal(5,2) DEFAULT 100.00,
|
||||
`targetMargin` decimal(5,2) DEFAULT 0.00,
|
||||
`createdAt` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
19
db/migrations/003_create_forecasting_tables.sql
Normal file
19
db/migrations/003_create_forecasting_tables.sql
Normal file
@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS `forecasting` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`projectId` INT NOT NULL,
|
||||
`versionNumber` INT NOT NULL,
|
||||
`createdAt` DATETIME NOT NULL,
|
||||
FOREIGN KEY (`projectId`) REFERENCES `projects`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `forecastAllocation` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`forecastingId` INT NOT NULL,
|
||||
`rosterId` INT NOT NULL,
|
||||
`resourceName` VARCHAR(255) NOT NULL,
|
||||
`level` VARCHAR(255) NOT NULL,
|
||||
`month` DATE NOT NULL,
|
||||
`allocatedDays` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
|
||||
FOREIGN KEY (`forecastingId`) REFERENCES `forecasting`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`rosterId`) REFERENCES `roster`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE `forecastAllocation` DROP INDEX `unique_allocation`, ADD UNIQUE KEY `unique_allocation` (`forecastingId`, `rosterId`, `month`);
|
||||
10
db/migrations/005_create_project_finance_monthly_table.sql
Normal file
10
db/migrations/005_create_project_finance_monthly_table.sql
Normal file
@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS `projectFinanceMonthly` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`projectId` INT NOT NULL,
|
||||
`metricName` VARCHAR(255) NOT NULL,
|
||||
`month` DATE NOT NULL,
|
||||
`createdAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updatedAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`projectId`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
UNIQUE KEY `project_metric_month` (`projectId`, `metricName`, `month`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
8
db/migrations/006_create_billing_monthly_table.sql
Normal file
8
db/migrations/006_create_billing_monthly_table.sql
Normal file
@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS `billingMonthly` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`projectId` INT NOT NULL,
|
||||
`month` DATE NOT NULL,
|
||||
`amount` DECIMAL(10, 2) NOT NULL DEFAULT 0.00,
|
||||
FOREIGN KEY (`projectId`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
UNIQUE KEY `project_month` (`projectId`, `month`)
|
||||
);
|
||||
3
db/migrations/007_add_revenue_fields_to_roster.sql
Normal file
3
db/migrations/007_add_revenue_fields_to_roster.sql
Normal file
@ -0,0 +1,3 @@
|
||||
ALTER TABLE `roster`
|
||||
ADD COLUMN `grossRevenue` DECIMAL(10, 2) NOT NULL DEFAULT 0.00,
|
||||
ADD COLUMN `discountedRevenue` DECIMAL(10, 2) NOT NULL DEFAULT 0.00;
|
||||
8
db/migrations/008_create_expenses_monthly_table.sql
Normal file
8
db/migrations/008_create_expenses_monthly_table.sql
Normal file
@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS `expensesMonthly` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`projectId` INT NOT NULL,
|
||||
`month` DATE NOT NULL,
|
||||
`amount` DECIMAL(10, 2) NOT NULL DEFAULT 0.00,
|
||||
FOREIGN KEY (`projectId`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
UNIQUE KEY `project_month` (`projectId`, `month`)
|
||||
);
|
||||
@ -0,0 +1,4 @@
|
||||
|
||||
ALTER TABLE `projectFinanceMonthly`
|
||||
ADD COLUMN `value` DECIMAL(15, 2) NOT NULL DEFAULT 0.00,
|
||||
ADD COLUMN `is_overridden` BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
3
db/migrations/010_fix_project_finance_monthly_table.sql
Normal file
3
db/migrations/010_fix_project_finance_monthly_table.sql
Normal file
@ -0,0 +1,3 @@
|
||||
ALTER TABLE `projectFinanceMonthly` ADD COLUMN `metricName` VARCHAR(255) NOT NULL AFTER `projectId`;
|
||||
ALTER TABLE `projectFinanceMonthly` ADD COLUMN `value` DECIMAL(15, 2) NOT NULL DEFAULT 0.00;
|
||||
ALTER TABLE `projectFinanceMonthly` ADD COLUMN `is_overridden` BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
34
db/migrations/011_restructure_project_finance_monthly.sql
Normal file
34
db/migrations/011_restructure_project_finance_monthly.sql
Normal file
@ -0,0 +1,34 @@
|
||||
-- Create a temporary table with the new structure
|
||||
CREATE TABLE `projectFinanceMonthly_new` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`projectId` INT NOT NULL,
|
||||
`month` DATE NOT NULL,
|
||||
`wip` DECIMAL(15, 2) DEFAULT 0.00,
|
||||
`opening_balance` DECIMAL(15, 2) DEFAULT 0.00,
|
||||
`billing` DECIMAL(15, 2) DEFAULT 0.00,
|
||||
`expenses` DECIMAL(15, 2) DEFAULT 0.00,
|
||||
`is_overridden` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`createdAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updatedAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`projectId`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
|
||||
UNIQUE KEY `project_month` (`projectId`, `month`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrate the data
|
||||
INSERT INTO `projectFinanceMonthly_new` (projectId, month, wip, opening_balance, billing, expenses, is_overridden)
|
||||
SELECT
|
||||
projectId,
|
||||
month,
|
||||
MAX(CASE WHEN metricName = 'wip' THEN value ELSE 0 END) as wip,
|
||||
MAX(CASE WHEN metricName = 'opening_balance' THEN value ELSE 0 END) as opening_balance,
|
||||
MAX(CASE WHEN metricName = 'billing' THEN value ELSE 0 END) as billing,
|
||||
MAX(CASE WHEN metricName = 'expenses' THEN value ELSE 0 END) as expenses,
|
||||
MAX(is_overridden) as is_overridden
|
||||
FROM projectFinanceMonthly
|
||||
GROUP BY projectId, month;
|
||||
|
||||
-- Drop the old table
|
||||
DROP TABLE projectFinanceMonthly;
|
||||
|
||||
-- Rename the new table
|
||||
RENAME TABLE projectFinanceMonthly_new TO projectFinanceMonthly;
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE `roster` DROP COLUMN `discountedRevenue`;
|
||||
121
expenses.php
Normal file
121
expenses.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$projectId = $_GET['id'] ?? null;
|
||||
if (!$projectId) {
|
||||
header("Location: projects.php");
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
// I will execute the migration to create the expensesMonthly table
|
||||
$migrationFile = __DIR__ . '/db/migrations/008_create_expenses_monthly_table.sql';
|
||||
if (file_exists($migrationFile)) {
|
||||
$sql = file_get_contents($migrationFile);
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM projects WHERE id = :id");
|
||||
$stmt->execute([':id' => $projectId]);
|
||||
$project = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$project) {
|
||||
header("Location: projects.php");
|
||||
exit();
|
||||
}
|
||||
|
||||
// Initialize expenses records if they don't exist
|
||||
$startDate = new DateTime($project['startDate']);
|
||||
$endDate = new DateTime($project['endDate']);
|
||||
$currentMonth = clone $startDate;
|
||||
|
||||
while ($currentMonth <= $endDate) {
|
||||
$monthStr = $currentMonth->format('Y-m-01');
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM expensesMonthly WHERE projectId = :projectId AND month = :month");
|
||||
$stmt->execute([':projectId' => $projectId, ':month' => $monthStr]);
|
||||
$count = $stmt->fetchColumn();
|
||||
|
||||
if ($count == 0) {
|
||||
$insertStmt = $pdo->prepare("INSERT INTO expensesMonthly (projectId, month, amount) VALUES (:projectId, :month, 0)");
|
||||
$insertStmt->execute([':projectId' => $projectId, ':month' => $monthStr]);
|
||||
}
|
||||
|
||||
$currentMonth->modify('+1 month');
|
||||
}
|
||||
|
||||
// Fetch expenses data
|
||||
$stmt = $pdo->prepare("SELECT * FROM expensesMonthly WHERE projectId = :projectId ORDER BY month");
|
||||
$stmt->execute([':projectId' => $projectId]);
|
||||
$expensesData = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$db_error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Expenses - <?php echo htmlspecialchars($project['name']); ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-wrapper">
|
||||
<main class="content-wrapper">
|
||||
<div class="page-header">
|
||||
<h1 class="h2">Expenses - <?php echo htmlspecialchars($project['name']); ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="project_details.php?id=<?php echo $projectId; ?>" class="btn btn-secondary">Return</a>
|
||||
<a href="export_expenses.php?id=<?php echo $projectId; ?>" class="btn btn-secondary">Export to Excel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (isset($db_error)): ?>
|
||||
<div class="alert alert-danger"><?php echo htmlspecialchars($db_error); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 150px;">Expenses</th>
|
||||
<?php
|
||||
$currentMonth = clone $startDate;
|
||||
while ($currentMonth <= $endDate) {
|
||||
echo '<th>' . $currentMonth->format('M Y') . '</th>';
|
||||
$currentMonth->modify('+1 month');
|
||||
}
|
||||
?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Expenses</td>
|
||||
<?php
|
||||
foreach ($expensesData as $expensesMonth) {
|
||||
echo '<td><input type="number" class="form-control expenses-amount" data-month="' . $expensesMonth['month'] . '" value="' . htmlspecialchars($expensesMonth['amount']) . '"></td>';
|
||||
}
|
||||
?>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="assets/js/expenses.js?v=<?php echo time(); ?>"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const projectId = <?php echo json_encode($projectId); ?>;
|
||||
initExpensesPage(projectId);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
42
export.php
Normal file
42
export.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
// --- DATABASE CONNECTION ---
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->query("SELECT * FROM roster ORDER BY fullNameEn");
|
||||
$roster_data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
die("Database error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// --- CSV EXPORT ---
|
||||
$filename = "roster_export_" . date('Y-m-d') . ".csv";
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Add headers
|
||||
if (!empty($roster_data)) {
|
||||
$roster_data_with_daily_cost = [];
|
||||
foreach ($roster_data as $row) {
|
||||
$row['dailyCost'] = $row['totalMonthlyCost'] / 20;
|
||||
$roster_data_with_daily_cost[] = $row;
|
||||
}
|
||||
fputcsv($output, array_keys($roster_data_with_daily_cost[0]));
|
||||
foreach ($roster_data_with_daily_cost as $row) {
|
||||
fputcsv($output, $row);
|
||||
}
|
||||
} else {
|
||||
// Add headers even if there is no data
|
||||
$stmt = $pdo->query("SHOW COLUMNS FROM roster");
|
||||
$columns = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
$columns[] = 'dailyCost';
|
||||
fputcsv($output, $columns);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit();
|
||||
50
export_billing.php
Normal file
50
export_billing.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$projectId = $_GET['id'] ?? null;
|
||||
if (!$projectId) {
|
||||
die("Project ID is required.");
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("SELECT name FROM projects WHERE id = :id");
|
||||
$stmt->execute([':id' => $projectId]);
|
||||
$project = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$projectName = $project ? $project['name'] : 'project';
|
||||
|
||||
$stmt = $pdo->prepare("SELECT month, amount FROM billingMonthly WHERE projectId = :projectId ORDER BY month");
|
||||
$stmt->execute([':projectId' => $projectId]);
|
||||
$billingData = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$filename = "billing_" . strtolower(str_replace(' ', '_', $projectName)) . ".csv";
|
||||
|
||||
header('Content-Type: text/csv');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Header row
|
||||
$header = ['Billing'];
|
||||
$totalBilling = 0;
|
||||
foreach ($billingData as $row) {
|
||||
$header[] = date("M Y", strtotime($row['month']));
|
||||
$totalBilling += $row['amount'];
|
||||
}
|
||||
$header[] = 'Total';
|
||||
fputcsv($output, $header);
|
||||
|
||||
// Data row
|
||||
$data = ['Billing'];
|
||||
foreach ($billingData as $row) {
|
||||
$data[] = $row['amount'];
|
||||
}
|
||||
$data[] = $totalBilling;
|
||||
fputcsv($output, $data);
|
||||
|
||||
fclose($output);
|
||||
exit();
|
||||
|
||||
} catch (PDOException $e) {
|
||||
die("Database error: " . $e->getMessage());
|
||||
}
|
||||
46
export_expenses.php
Normal file
46
export_expenses.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$projectId = $_GET['id'] ?? null;
|
||||
if (!$projectId) {
|
||||
die("Project ID is required.");
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("SELECT name FROM projects WHERE id = :id");
|
||||
$stmt->execute([':id' => $projectId]);
|
||||
$project = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$projectName = $project ? $project['name'] : 'project';
|
||||
|
||||
$stmt = $pdo->prepare("SELECT month, amount FROM expensesMonthly WHERE projectId = :projectId ORDER BY month");
|
||||
$stmt->execute([':projectId' => $projectId]);
|
||||
$expensesData = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$filename = "expenses_" . strtolower(str_replace(' ', '_', $projectName)) . ".csv";
|
||||
|
||||
header('Content-Type: text/csv');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Header row
|
||||
$header = ['Expenses'];
|
||||
foreach ($expensesData as $row) {
|
||||
$header[] = date("M Y", strtotime($row['month']));
|
||||
}
|
||||
fputcsv($output, $header);
|
||||
|
||||
// Data row
|
||||
$data = ['Expenses'];
|
||||
foreach ($expensesData as $row) {
|
||||
$data[] = $row['amount'];
|
||||
}
|
||||
fputcsv($output, $data);
|
||||
|
||||
fclose($output);
|
||||
exit();
|
||||
|
||||
} catch (PDOException $e) {
|
||||
die("Database error: " . $e->getMessage());
|
||||
}
|
||||
205
export_project_finance.php
Normal file
205
export_project_finance.php
Normal file
@ -0,0 +1,205 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
// --- DATA FETCHING & CALCULATION ---
|
||||
$project = null;
|
||||
$project_id = $_GET['project_id'] ?? null;
|
||||
$financial_data = [];
|
||||
$months = [];
|
||||
$metrics = ["Opening Balance", "Billings", "WIP", "Expenses", "Cost", "NSR", "Margin"];
|
||||
|
||||
if (!$project_id) {
|
||||
header("HTTP/1.0 400 Bad Request");
|
||||
echo "Project ID is required.";
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("SELECT * FROM projects WHERE id = :id");
|
||||
$stmt->execute([':id' => $project_id]);
|
||||
$project = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($project) {
|
||||
function get_month_range($start_date_str, $end_date_str) {
|
||||
$months = [];
|
||||
if (empty($start_date_str) || empty($end_date_str)) return $months;
|
||||
$start = new DateTime($start_date_str);
|
||||
$end = new DateTime($end_date_str);
|
||||
$current = clone $start;
|
||||
$current->modify('first day of this month');
|
||||
while ($current <= $end) {
|
||||
$months[] = $current->format('Y-m-01');
|
||||
$current->modify('+1 month');
|
||||
}
|
||||
return $months;
|
||||
}
|
||||
$months = get_month_range($project['startDate'], $project['endDate']);
|
||||
|
||||
// --- Financial Calculations ---
|
||||
|
||||
// 1. Fetch base monthly data
|
||||
$monthly_costs = [];
|
||||
$monthly_wip = [];
|
||||
|
||||
$forecast_stmt = $pdo->prepare("SELECT id FROM forecasting WHERE projectId = :pid ORDER BY versionNumber DESC LIMIT 1");
|
||||
$forecast_stmt->execute([':pid' => $project_id]);
|
||||
$latest_forecast = $forecast_stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($latest_forecast) {
|
||||
$fid = $latest_forecast['id'];
|
||||
|
||||
// Base Monthly Cost
|
||||
$cost_sql = "SELECT fa.month, SUM(fa.allocatedDays * (r.totalMonthlyCost / 20)) FROM forecastAllocation fa JOIN roster r ON fa.rosterId = r.id WHERE fa.forecastingId = :fid GROUP BY fa.month";
|
||||
$cost_stmt = $pdo->prepare($cost_sql);
|
||||
$cost_stmt->execute([':fid' => $fid]);
|
||||
$monthly_costs = $cost_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// Base Monthly WIP
|
||||
$recoverability_decimal = $project['recoverability'] / 100;
|
||||
$wip_sql = "SELECT fa.month, SUM(fa.allocatedDays * (r.grossRevenue * (1 - :recoverability))) as wip FROM forecastAllocation fa JOIN roster r ON fa.rosterId = r.id WHERE fa.forecastingId = :fid GROUP BY fa.month";
|
||||
$wip_stmt = $pdo->prepare($wip_sql);
|
||||
$wip_stmt->execute([':fid' => $fid, ':recoverability' => $recoverability_decimal]);
|
||||
$monthly_wip = $wip_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
}
|
||||
|
||||
// Base Monthly Billings
|
||||
$billing_stmt = $pdo->prepare("SELECT month, amount FROM billingMonthly WHERE projectId = :pid");
|
||||
$billing_stmt->execute([':pid' => $project_id]);
|
||||
$monthly_billing = $billing_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// Base Monthly Expenses
|
||||
$expenses_stmt = $pdo->prepare("SELECT month, amount FROM expensesMonthly WHERE projectId = :pid");
|
||||
$expenses_stmt->execute([':pid' => $project_id]);
|
||||
$monthly_expenses = $expenses_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// 2. Calculate and process values month by month, including overrides
|
||||
$cumulative_billing = 0;
|
||||
$cumulative_cost = 0;
|
||||
$cumulative_expenses = 0;
|
||||
$previous_month_opening_balance = 0; // Initialize Opening Balance for the first month.
|
||||
|
||||
// Fetch all overridden data at once to avoid querying in a loop
|
||||
$override_stmt = $pdo->prepare("SELECT month, metricName, value FROM projectFinanceMonthly WHERE projectId = :pid AND is_overridden = 1");
|
||||
$override_stmt->execute([':pid' => $project_id]);
|
||||
$overridden_rows = $override_stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$overrides = [];
|
||||
$metric_name_map = [
|
||||
'wip' => 'WIP',
|
||||
'opening_balance' => 'Opening Balance',
|
||||
'billing' => 'Billings',
|
||||
'expenses' => 'Expenses',
|
||||
'cost' => 'Cost',
|
||||
'nsr' => 'NSR',
|
||||
'margin' => 'Margin'
|
||||
];
|
||||
foreach ($overridden_rows as $row) {
|
||||
if (isset($metric_name_map[$row['metricName']])) {
|
||||
$metric_display_name = $metric_name_map[$row['metricName']];
|
||||
$overrides[$row['month']][$metric_display_name] = $row['value'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($months as $month) {
|
||||
// --- Opening Balance ---
|
||||
// Opening Balance of the current month is the Opening Balance of the previous month.
|
||||
$opening_balance = $previous_month_opening_balance;
|
||||
$financial_data['Opening Balance'][$month] = $opening_balance;
|
||||
if (isset($overrides[$month]['Opening Balance'])) {
|
||||
$financial_data['Opening Balance'][$month] = $overrides[$month]['Opening Balance'];
|
||||
}
|
||||
|
||||
// --- Base Data ---
|
||||
$cost = $monthly_costs[$month] ?? 0;
|
||||
$base_monthly_wip = $monthly_wip[$month] ?? 0;
|
||||
$billing = $monthly_billing[$month] ?? 0;
|
||||
$expenses = $monthly_expenses[$month] ?? 0;
|
||||
|
||||
// --- Cumulative Metrics ---
|
||||
$cumulative_billing += $billing;
|
||||
$financial_data['Billings'][$month] = $cumulative_billing;
|
||||
if (isset($overrides[$month]['Billings'])) {
|
||||
$financial_data['Billings'][$month] = $overrides[$month]['Billings'];
|
||||
$cumulative_billing = $financial_data['Billings'][$month];
|
||||
}
|
||||
|
||||
$cumulative_cost += $cost;
|
||||
$financial_data['Cost'][$month] = $cumulative_cost;
|
||||
if (isset($overrides[$month]['Cost'])) {
|
||||
$financial_data['Cost'][$month] = $overrides[$month]['Cost'];
|
||||
$cumulative_cost = $financial_data['Cost'][$month];
|
||||
}
|
||||
|
||||
$cumulative_expenses += $expenses;
|
||||
$financial_data['Expenses'][$month] = $cumulative_expenses;
|
||||
if (isset($overrides[$month]['Expenses'])) {
|
||||
$financial_data['Expenses'][$month] = $overrides[$month]['Expenses'];
|
||||
$cumulative_expenses = $financial_data['Expenses'][$month];
|
||||
}
|
||||
|
||||
// --- WIP (Closing Balance) ---
|
||||
$final_opening_balance = $financial_data['Opening Balance'][$month];
|
||||
$current_wip = $final_opening_balance + $expenses + $base_monthly_wip - $billing;
|
||||
$financial_data['WIP'][$month] = $current_wip;
|
||||
if (isset($overrides[$month]['WIP'])) {
|
||||
$financial_data['WIP'][$month] = $overrides[$month]['WIP'];
|
||||
}
|
||||
|
||||
// --- Calculated Metrics (NSR and Margin) ---
|
||||
$nsr = $financial_data['WIP'][$month] + $financial_data['Billings'][$month] - $financial_data['Opening Balance'][$month] - $financial_data['Expenses'][$month];
|
||||
$financial_data['NSR'][$month] = $nsr;
|
||||
if (isset($overrides[$month]['NSR'])) {
|
||||
$financial_data['NSR'][$month] = $overrides[$month]['NSR'];
|
||||
}
|
||||
|
||||
$final_nsr = $financial_data['NSR'][$month];
|
||||
$final_cost = $financial_data['Cost'][$month];
|
||||
$margin = ($final_nsr != 0) ? (($final_nsr - $final_cost) / $final_nsr) : 0;
|
||||
$financial_data['Margin'][$month] = $margin;
|
||||
if (isset($overrides[$month]['Margin'])) {
|
||||
$financial_data['Margin'][$month] = $overrides[$month]['Margin'];
|
||||
}
|
||||
|
||||
// --- Carry-over for next iteration ---
|
||||
// The next month's opening balance is this month's final opening balance (including override).
|
||||
$previous_month_opening_balance = $financial_data['Opening Balance'][$month];
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
header("HTTP/1.0 500 Internal Server Error");
|
||||
echo "Database error: " . $e->getMessage();
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$project) {
|
||||
header("HTTP/1.0 404 Not Found");
|
||||
echo "Project not found.";
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- CSV EXPORT ---
|
||||
$filename = "project_finance_" . preg_replace('/[^a-zA-Z0-9_]/', '_', $project['name']) . ".csv";
|
||||
|
||||
header('Content-Type: text/csv');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Header row
|
||||
$header = ['Metric'];
|
||||
foreach ($months as $month) {
|
||||
$header[] = date('M Y', strtotime($month));
|
||||
}
|
||||
fputcsv($output, $header);
|
||||
|
||||
// Data rows
|
||||
foreach ($metrics as $metric) {
|
||||
$row = [$metric];
|
||||
foreach ($months as $month) {
|
||||
$row[] = number_format($financial_data[$metric][$month] ?? 0, 2);
|
||||
}
|
||||
fputcsv($output, $row);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
264
forecasting.php
Normal file
264
forecasting.php
Normal file
@ -0,0 +1,264 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$projectId = $_GET['projectId'] ?? null;
|
||||
$versionNumber = $_GET['version'] ?? null;
|
||||
|
||||
if (!$projectId) {
|
||||
die("Project ID is required.");
|
||||
}
|
||||
|
||||
function execute_sql_from_file($pdo, $filepath) {
|
||||
try {
|
||||
$sql = file_get_contents($filepath);
|
||||
$pdo->exec($sql);
|
||||
return true;
|
||||
} catch (PDOException $e) {
|
||||
if (strpos($e->getMessage(), 'already exists') === false) {
|
||||
error_log("SQL Execution Error: " . $e->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$project = null;
|
||||
$versions = [];
|
||||
$allocations = [];
|
||||
$roster_for_search = [];
|
||||
$db_error = null;
|
||||
|
||||
function get_months($startDate, $endDate) {
|
||||
$start = new DateTime($startDate);
|
||||
$end = new DateTime($endDate);
|
||||
$interval = new DateInterval('P1M');
|
||||
$period = new DatePeriod($start, $interval, $end->modify('+1 month'));
|
||||
$months = [];
|
||||
foreach ($period as $dt) {
|
||||
$months[] = $dt->format('Y-m-01');
|
||||
}
|
||||
return $months;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Apply all migrations
|
||||
$migration_files = glob(__DIR__ . '/db/migrations/*.sql');
|
||||
sort($migration_files);
|
||||
foreach ($migration_files as $file) {
|
||||
execute_sql_from_file($pdo, $file);
|
||||
}
|
||||
|
||||
// Fetch project details
|
||||
$stmt = $pdo->prepare("SELECT * FROM projects WHERE id = :projectId");
|
||||
$stmt->execute([':projectId' => $projectId]);
|
||||
$project = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$project) {
|
||||
die("Project not found.");
|
||||
}
|
||||
|
||||
// Fetch forecasting versions
|
||||
$stmt = $pdo->prepare("SELECT * FROM forecasting WHERE projectId = :projectId ORDER BY versionNumber DESC");
|
||||
$stmt->execute([':projectId' => $projectId]);
|
||||
$versions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($versionNumber) && !empty($versions)) {
|
||||
$versionNumber = $versions[0]['versionNumber'];
|
||||
}
|
||||
|
||||
$selected_version = null;
|
||||
if ($versionNumber) {
|
||||
foreach($versions as $v) {
|
||||
if ($v['versionNumber'] == $versionNumber) {
|
||||
$selected_version = $v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch allocations for the selected version
|
||||
if ($selected_version) {
|
||||
$months = get_months($project['startDate'], $project['endDate']);
|
||||
$sql = "SELECT
|
||||
fa.rosterId,
|
||||
fa.resourceName,
|
||||
fa.level,
|
||||
GROUP_CONCAT(fa.month, '|', fa.allocatedDays ORDER BY fa.month) as monthly_allocations
|
||||
FROM forecastAllocation fa
|
||||
WHERE fa.forecastingId = :forecastingId
|
||||
GROUP BY fa.rosterId, fa.resourceName, fa.level
|
||||
ORDER BY fa.resourceName";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([':forecastingId' => $selected_version['id']]);
|
||||
$raw_allocations = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Process allocations into a structured array
|
||||
foreach ($raw_allocations as $raw_row) {
|
||||
$alloc_row = [
|
||||
'rosterId' => $raw_row['rosterId'],
|
||||
'resourceName' => $raw_row['resourceName'],
|
||||
'level' => $raw_row['level'],
|
||||
'months' => []
|
||||
];
|
||||
foreach($months as $month) {
|
||||
$alloc_row['months'][$month] = 0; // Default
|
||||
}
|
||||
$monthly_pairs = explode(',', $raw_row['monthly_allocations']);
|
||||
foreach ($monthly_pairs as $pair) {
|
||||
list($month, $days) = explode('|', $pair);
|
||||
$alloc_row['months'][$month] = $days;
|
||||
}
|
||||
$allocations[] = $alloc_row;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch roster for search
|
||||
$stmt = $pdo->query("SELECT id, sapCode, fullNameEn, `level` FROM roster ORDER BY fullNameEn");
|
||||
$roster_for_search = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$db_error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
|
||||
$months_headers = $project ? get_months($project['startDate'], $project['endDate']) : [];
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Forecasting - <?php echo htmlspecialchars($project['name'] ?? ''); ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" />
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-navbar">
|
||||
Project Financials
|
||||
</div>
|
||||
<div class="main-wrapper">
|
||||
<nav class="sidebar">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="roster.php"><i class="bi bi-people-fill me-2"></i>Roster</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="projects.php"><i class="bi bi-briefcase-fill me-2"></i>Projects</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<?php if ($db_error): ?>
|
||||
<div class="alert alert-danger"><?php echo htmlspecialchars($db_error); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="h2">Forecasting: <?php echo htmlspecialchars($project['name'] ?? 'N/A'); ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="project_details.php?id=<?php echo $projectId; ?>" class="btn btn-secondary"><i class="bi bi-arrow-left-circle me-2"></i>Back to Project</a>
|
||||
<form action="forecasting_actions.php" method="POST" class="d-inline">
|
||||
<input type="hidden" name="action" value="new_version">
|
||||
<input type="hidden" name="projectId" value="<?php echo $projectId; ?>">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-plus-circle-fill me-2"></i>New Version</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-4">
|
||||
<label for="versionSelector" class="form-label">Select Version</label>
|
||||
<select class="form-select" id="versionSelector">
|
||||
<?php foreach ($versions as $v): ?>
|
||||
<option value="<?php echo $v['versionNumber']; ?>" <?php echo ($v['versionNumber'] == $versionNumber) ? 'selected' : ''; ?>>
|
||||
Version <?php echo $v['versionNumber']; ?> (<?php echo date("d M Y, H:i", strtotime($v['createdAt'])); ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Resource Allocations</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="forecasting_actions.php" method="POST" class="row g-3 align-items-end mb-4">
|
||||
<input type="hidden" name="action" value="add_resource">
|
||||
<input type="hidden" name="projectId" value="<?php echo $projectId; ?>">
|
||||
<input type="hidden" name="forecastingId" value="<?php echo $selected_version['id'] ?? ''; ?>">
|
||||
<div class="col-md-6">
|
||||
<label for="rosterSearch" class="form-label">Add Resource</label>
|
||||
<select class="form-control" id="rosterSearch" name="rosterId" required>
|
||||
<option></option> <!-- Placeholder for Select2 -->
|
||||
<?php foreach ($roster_for_search as $resource): ?>
|
||||
<option value="<?php echo $resource['id']; ?>" data-level="<?php echo htmlspecialchars($resource['level']); ?>">
|
||||
<?php echo htmlspecialchars($resource['fullNameEn'] . ' (' . $resource['sapCode'] . ')'); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-success">Add to Forecast</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Resource Name</th>
|
||||
<th>Level</th>
|
||||
<?php foreach ($months_headers as $month): ?>
|
||||
<th><?php echo date("M Y", strtotime($month)); ?></th>
|
||||
<?php endforeach; ?>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($allocations)): ?>
|
||||
<tr>
|
||||
<td colspan="<?php echo count($months_headers) + 3; ?>" class="text-center text-secondary">No resources allocated yet.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($allocations as $alloc_row): ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($alloc_row['resourceName']); ?></td>
|
||||
<td><?php echo htmlspecialchars($alloc_row['level']); ?></td>
|
||||
<?php foreach ($alloc_row['months'] as $month => $days): ?>
|
||||
<td class="editable"
|
||||
data-month="<?php echo $month; ?>"
|
||||
data-roster-id="<?php echo $alloc_row['rosterId']; ?>"
|
||||
data-forecasting-id="<?php echo $selected_version['id']; ?>">
|
||||
<?php echo htmlspecialchars($days); ?>
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
<td>
|
||||
<form action="forecasting_actions.php" method="POST" onsubmit="return confirm('Remove this resource from the forecast?');">
|
||||
<input type="hidden" name="action" value="remove_resource">
|
||||
<input type="hidden" name="projectId" value="<?php echo $projectId; ?>">
|
||||
<input type="hidden" name="forecastingId" value="<?php echo $selected_version['id']; ?>">
|
||||
<input type="hidden" name="rosterId" value="<?php echo $alloc_row['rosterId']; ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script src="assets/js/forecasting.js?v=<?php echo time(); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
186
forecasting_actions.php
Normal file
186
forecasting_actions.php
Normal file
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$action = $_POST['action'] ?? null;
|
||||
$projectId = $_POST['projectId'] ?? null;
|
||||
|
||||
if (!$action || !$projectId) {
|
||||
redirect_with_error('forecasting.php', $projectId, 'Invalid request.');
|
||||
}
|
||||
|
||||
function redirect_with_error($page, $projectId, $message) {
|
||||
$version = $_POST['version'] ?? null;
|
||||
$query = http_build_query(array_filter([
|
||||
'projectId' => $projectId,
|
||||
'version' => $version,
|
||||
'error' => $message
|
||||
]));
|
||||
header("Location: {$page}?{$query}");
|
||||
exit();
|
||||
}
|
||||
|
||||
function redirect_with_success($page, $projectId, $message) {
|
||||
$version = $_POST['version'] ?? null;
|
||||
$query = http_build_query(array_filter([
|
||||
'projectId' => $projectId,
|
||||
'version' => $version,
|
||||
'success' => $message
|
||||
]));
|
||||
header("Location: {$page}?{$query}");
|
||||
exit();
|
||||
}
|
||||
|
||||
function get_months($startDate, $endDate) {
|
||||
$start = new DateTime($startDate);
|
||||
$end = new DateTime($endDate);
|
||||
$interval = new DateInterval('P1M');
|
||||
$period = new DatePeriod($start, $interval, $end->modify('+1 month'));
|
||||
$months = [];
|
||||
foreach ($period as $dt) {
|
||||
$months[] = $dt->format('Y-m-01');
|
||||
}
|
||||
return $months;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($action) {
|
||||
case 'new_version':
|
||||
// Find the latest version number
|
||||
$stmt = $pdo->prepare("SELECT MAX(versionNumber) as max_version FROM forecasting WHERE projectId = :projectId");
|
||||
$stmt->execute([':projectId' => $projectId]);
|
||||
$latest_version_num = $stmt->fetchColumn();
|
||||
$new_version_num = $latest_version_num + 1;
|
||||
|
||||
// Get the ID of the latest version to clone from
|
||||
$stmt = $pdo->prepare("SELECT id FROM forecasting WHERE projectId = :projectId AND versionNumber = :versionNumber");
|
||||
$stmt->execute([':projectId' => $projectId, ':versionNumber' => $latest_version_num]);
|
||||
$latest_version_id = $stmt->fetchColumn();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Create the new forecasting version
|
||||
$stmt = $pdo->prepare("INSERT INTO forecasting (projectId, versionNumber, createdAt) VALUES (:projectId, :versionNumber, NOW())");
|
||||
$stmt->execute([':projectId' => $projectId, ':versionNumber' => $new_version_num]);
|
||||
$new_version_id = $pdo->lastInsertId();
|
||||
|
||||
// Clone allocations from the latest version if it exists
|
||||
if ($latest_version_id) {
|
||||
$clone_sql = "INSERT INTO forecastAllocation (forecastingId, rosterId, resourceName, level, month, allocatedDays)
|
||||
SELECT :new_version_id, rosterId, resourceName, level, month, allocatedDays
|
||||
FROM forecastAllocation WHERE forecastingId = :latest_version_id";
|
||||
$stmt = $pdo->prepare($clone_sql);
|
||||
$stmt->execute([':new_version_id' => $new_version_id, ':latest_version_id' => $latest_version_id]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
redirect_with_success('forecasting.php', $projectId, 'New version created successfully.', ['version' => $new_version_num]);
|
||||
break;
|
||||
|
||||
case 'add_resource':
|
||||
$forecastingId = $_POST['forecastingId'] ?? null;
|
||||
$rosterId = $_POST['rosterId'] ?? null;
|
||||
|
||||
if (!$forecastingId || !$rosterId) {
|
||||
redirect_with_error('forecasting.php', $projectId, 'Missing data for adding resource.');
|
||||
}
|
||||
|
||||
// Fetch project and roster details
|
||||
$stmt = $pdo->prepare("SELECT startDate, endDate FROM projects WHERE id = :projectId");
|
||||
$stmt->execute([':projectId' => $projectId]);
|
||||
$project = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$stmt = $pdo->prepare("SELECT fullNameEn, `level` FROM roster WHERE id = :rosterId");
|
||||
$stmt->execute([':rosterId' => $rosterId]);
|
||||
$roster = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$project || !$roster) {
|
||||
redirect_with_error('forecasting.php', $projectId, 'Project or resource not found.');
|
||||
}
|
||||
|
||||
$months = get_months($project['startDate'], $project['endDate']);
|
||||
$insert_sql = "INSERT INTO forecastAllocation (forecastingId, rosterId, resourceName, level, month, allocatedDays) VALUES (:forecastingId, :rosterId, :resourceName, :level, :month, 0)";
|
||||
$stmt = $pdo->prepare($insert_sql);
|
||||
|
||||
$pdo->beginTransaction();
|
||||
foreach ($months as $month) {
|
||||
$stmt->execute([
|
||||
':forecastingId' => $forecastingId,
|
||||
':rosterId' => $rosterId,
|
||||
':resourceName' => $roster['fullNameEn'],
|
||||
':level' => $roster['level'],
|
||||
':month' => $month
|
||||
]);
|
||||
}
|
||||
$pdo->commit();
|
||||
|
||||
redirect_with_success('forecasting.php', $projectId, 'Resource added to forecast.');
|
||||
break;
|
||||
|
||||
case 'remove_resource':
|
||||
$forecastingId = $_POST['forecastingId'] ?? null;
|
||||
$rosterId = $_POST['rosterId'] ?? null;
|
||||
|
||||
if (!$forecastingId || !$rosterId) {
|
||||
redirect_with_error('forecasting.php', $projectId, 'Missing data for removing resource.');
|
||||
}
|
||||
|
||||
$sql = "DELETE FROM forecastAllocation WHERE forecastingId = :forecastingId AND rosterId = :rosterId";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([':forecastingId' => $forecastingId, ':rosterId' => $rosterId]);
|
||||
|
||||
redirect_with_success('forecasting.php', $projectId, 'Resource removed from forecast.');
|
||||
break;
|
||||
|
||||
case 'update_allocation':
|
||||
header('Content-Type: application/json');
|
||||
$forecastingId = $_POST['forecastingId'] ?? null;
|
||||
$rosterId = $_POST['rosterId'] ?? null;
|
||||
$month = $_POST['month'] ?? null;
|
||||
$allocatedDays = $_POST['allocatedDays'] ?? null;
|
||||
|
||||
if (!$forecastingId || !$rosterId || !$month || !isset($allocatedDays)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid data for update.']);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Use INSERT ... ON DUPLICATE KEY UPDATE to handle both new and existing cells
|
||||
$sql = "INSERT INTO forecastAllocation (forecastingId, rosterId, month, allocatedDays, resourceName, level)
|
||||
VALUES (:forecastingId, :rosterId, :month, :allocatedDays,
|
||||
(SELECT fullNameEn FROM roster WHERE id = :rosterId),
|
||||
(SELECT `level` FROM roster WHERE id = :rosterId))
|
||||
ON DUPLICATE KEY UPDATE allocatedDays = :allocatedDays";
|
||||
|
||||
// We need a unique key on (forecastingId, rosterId, month) for this to work.
|
||||
// Let's assume it exists. If not, we need to add it.
|
||||
// ALTER TABLE forecastAllocation ADD UNIQUE KEY `unique_allocation` (`forecastingId`, `rosterId`, `month`);
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':forecastingId' => $forecastingId,
|
||||
':rosterId' => $rosterId,
|
||||
':month' => $month,
|
||||
':allocatedDays' => $allocatedDays
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
exit();
|
||||
|
||||
default:
|
||||
redirect_with_error('forecasting.php', $projectId, 'Invalid action specified.');
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
if ($action === 'update_allocation') {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Database error: ' . $e->getMessage()]);
|
||||
} else {
|
||||
redirect_with_error('forecasting.php', $projectId, 'Database error: ' . $e->getMessage());
|
||||
}
|
||||
exit();
|
||||
}
|
||||
134
import.php
Normal file
134
import.php
Normal file
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
session_start();
|
||||
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
function redirect_with_message($status, $message) {
|
||||
header("Location: roster.php?import_status=$status&import_message=" . urlencode($message));
|
||||
exit();
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['importFile'])) {
|
||||
redirect_with_message('error', 'Invalid request.');
|
||||
}
|
||||
|
||||
$file = $_FILES['importFile'];
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
redirect_with_message('error', 'File upload failed with error code: ' . $file['error']);
|
||||
}
|
||||
|
||||
$file_ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if ($file_ext !== 'csv') {
|
||||
redirect_with_message('error', 'Invalid file type. Only .csv files are accepted.');
|
||||
}
|
||||
|
||||
$handle = fopen($file['tmp_name'], 'r');
|
||||
if ($handle === false) {
|
||||
redirect_with_message('error', 'Could not open the uploaded file.');
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$header = fgetcsv($handle);
|
||||
if ($header === false) {
|
||||
redirect_with_message('error', 'Could not read the CSV header.');
|
||||
}
|
||||
|
||||
// Normalize headers to camelCase
|
||||
$normalized_header = array_map(function($h) {
|
||||
$h = trim($h);
|
||||
$h = preg_replace('/[^a-zA-Z0-9_\s]/', '', $h); // Remove special chars
|
||||
$h = preg_replace('/\s+/', ' ', $h); // Normalize whitespace
|
||||
$h = str_replace(' ', '', ucwords(strtolower($h))); // To PascalCase
|
||||
return lcfirst($h); // To camelCase
|
||||
}, $header);
|
||||
|
||||
$expected_headers = [
|
||||
'sapCode', 'fullNameEn', 'legalEntity', 'functionBusinessUnit', 'costCenterCode', 'level',
|
||||
'newAmendedSalary', 'employerContributions', 'cars', 'ticketRestaurant', 'metlife', 'topusPerMonth'
|
||||
];
|
||||
|
||||
$col_map = array_flip($normalized_header);
|
||||
|
||||
foreach ($expected_headers as $expected) {
|
||||
if (!isset($col_map[$expected])) {
|
||||
redirect_with_message('error', "Missing required column: '{$expected}'. Please check the CSV file header.");
|
||||
}
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO roster (sapCode, fullNameEn, legalEntity, functionBusinessUnit, costCenterCode, `level`, newAmendedSalary, employerContributions, cars, ticketRestaurant, metlife, topusPerMonth, totalSalaryCostWithLabor, totalMonthlyCost, totalAnnualCost)
|
||||
VALUES (:sapCode, :fullNameEn, :legalEntity, :functionBusinessUnit, :costCenterCode, :level, :newAmendedSalary, :employerContributions, :cars, :ticketRestaurant, :metlife, :topusPerMonth, :totalSalaryCostWithLabor, :totalMonthlyCost, :totalAnnualCost)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
fullNameEn = VALUES(fullNameEn),
|
||||
legalEntity = VALUES(legalEntity),
|
||||
functionBusinessUnit = VALUES(functionBusinessUnit),
|
||||
costCenterCode = VALUES(costCenterCode),
|
||||
`level` = VALUES(`level`),
|
||||
newAmendedSalary = VALUES(newAmendedSalary),
|
||||
employerContributions = VALUES(employerContributions),
|
||||
cars = VALUES(cars),
|
||||
ticketRestaurant = VALUES(ticketRestaurant),
|
||||
metlife = VALUES(metlife),
|
||||
topusPerMonth = VALUES(topusPerMonth),
|
||||
totalSalaryCostWithLabor = VALUES(totalSalaryCostWithLabor),
|
||||
totalMonthlyCost = VALUES(totalMonthlyCost),
|
||||
totalAnnualCost = VALUES(totalAnnualCost)";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
|
||||
$rows_processed = 0;
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
$data = array_combine(array_keys($col_map), $row);
|
||||
|
||||
$sapCode = $data['sapCode'] ?? null;
|
||||
if (empty($sapCode)) {
|
||||
continue; // Skip rows without a SAP Code
|
||||
}
|
||||
|
||||
// Prepare data and perform calculations
|
||||
$newAmendedSalary = (float)($data['newAmendedSalary'] ?? 0);
|
||||
$employerContributions = (float)($data['employerContributions'] ?? 0);
|
||||
$cars = (float)($data['cars'] ?? 0);
|
||||
$ticketRestaurant = (float)($data['ticketRestaurant'] ?? 0);
|
||||
$metlife = (float)($data['metlife'] ?? 0);
|
||||
$topusPerMonth = (float)($data['topusPerMonth'] ?? 0);
|
||||
|
||||
$totalSalaryCostWithLabor = $newAmendedSalary + $employerContributions;
|
||||
$totalMonthlyCost = $totalSalaryCostWithLabor + $cars + $ticketRestaurant + $metlife + $topusPerMonth;
|
||||
$totalAnnualCost = $totalMonthlyCost * 14;
|
||||
|
||||
$stmt->execute([
|
||||
':sapCode' => $sapCode,
|
||||
':fullNameEn' => $data['fullNameEn'] ?? null,
|
||||
':legalEntity' => $data['legalEntity'] ?? null,
|
||||
':functionBusinessUnit' => $data['functionBusinessUnit'] ?? null,
|
||||
':costCenterCode' => $data['costCenterCode'] ?? null,
|
||||
':level' => $data['level'] ?? null,
|
||||
':newAmendedSalary' => $newAmendedSalary,
|
||||
':employerContributions' => $employerContributions,
|
||||
':cars' => $cars,
|
||||
':ticketRestaurant' => $ticketRestaurant,
|
||||
':metlife' => $metlife,
|
||||
':topusPerMonth' => $topusPerMonth,
|
||||
':totalSalaryCostWithLabor' => $totalSalaryCostWithLabor,
|
||||
':totalMonthlyCost' => $totalMonthlyCost,
|
||||
':totalAnnualCost' => $totalAnnualCost
|
||||
]);
|
||||
$rows_processed++;
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
fclose($handle);
|
||||
redirect_with_message('success', "Successfully imported {$rows_processed} records.");
|
||||
|
||||
} catch (Exception $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
fclose($handle);
|
||||
error_log("Import Error: " . $e->getMessage());
|
||||
redirect_with_message('error', 'An error occurred during the import process. Details: ' . $e->getMessage());
|
||||
}
|
||||
189
index.php
189
index.php
@ -1,150 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
try {
|
||||
$pdo = db();
|
||||
$migration_files = glob(__DIR__ . '/db/migrations/*.sql');
|
||||
sort($migration_files);
|
||||
foreach ($migration_files as $file) {
|
||||
$sql = file_get_contents($file);
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
// If the table already exists, ignore the error
|
||||
if (strpos($e->getMessage(), 'already exists') === false) {
|
||||
die("Database migration failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Financial Management</title>
|
||||
<meta name="description" content="Project Financial Management Tool">
|
||||
<meta property="og:title" content="Project Financial Management">
|
||||
<meta property="og:description" content="Manage your project financials, roster, and budget.">
|
||||
<meta property="og:image" content="<?php echo htmlspecialchars($_SERVER['PROJECT_IMAGE_URL'] ?? ''); ?>">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<div class="top-navbar">
|
||||
Project Financial Management
|
||||
</div>
|
||||
<div class="main-wrapper">
|
||||
<nav class="sidebar">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="roster.php"><i class="bi bi-people-fill me-2"></i>Roster</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="projects.php"><i class="bi bi-briefcase-fill me-2"></i>Projects</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="content-wrapper">
|
||||
<div class="page-header">
|
||||
<h1 class="h2">Welcome to Project Financial Management</h1>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p>This is the landing page. You can navigate to the Roster or Projects page using the sidebar.</p>
|
||||
</div>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
385
project_details.php
Normal file
385
project_details.php
Normal file
@ -0,0 +1,385 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
// --- DATABASE MIGRATION ---
|
||||
function execute_sql_from_file($pdo, $filepath) {
|
||||
try {
|
||||
$sql = file_get_contents($filepath);
|
||||
if ($sql === false) return false;
|
||||
$pdo->exec($sql);
|
||||
return true;
|
||||
} catch (PDOException $e) {
|
||||
// Ignore errors about tables/columns that already exist
|
||||
if (strpos($e->getMessage(), 'already exists') === false && strpos($e->getMessage(), 'Duplicate column name') === false) {
|
||||
error_log("SQL Execution Error in $filepath: " . $e->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$migration_files = glob(__DIR__ . '/db/migrations/*.sql');
|
||||
sort($migration_files);
|
||||
foreach ($migration_files as $file) {
|
||||
execute_sql_from_file($pdo, $file);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
$db_error = "Database connection failed: " . $e->getMessage();
|
||||
// Stop execution if DB connection fails
|
||||
die($db_error);
|
||||
}
|
||||
|
||||
// --- DATA FETCHING & CALCULATION ---
|
||||
$project = null;
|
||||
$project_id = $_GET['id'] ?? null;
|
||||
$financial_data = [];
|
||||
$months = [];
|
||||
$metrics = ["Opening Balance", "Billings", "WIP", "Expenses", "Cost", "NSR", "Margin"];
|
||||
|
||||
if ($project_id) {
|
||||
$stmt = $pdo->prepare("SELECT * FROM projects WHERE id = :id");
|
||||
$stmt->execute([':id' => $project_id]);
|
||||
$project = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($project) {
|
||||
// Helper to generate month range
|
||||
function get_month_range($start_date_str, $end_date_str) {
|
||||
$months = [];
|
||||
if (empty($start_date_str) || empty($end_date_str)) return $months;
|
||||
$start = new DateTime($start_date_str);
|
||||
$end = new DateTime($end_date_str);
|
||||
$current = clone $start;
|
||||
$current->modify('first day of this month');
|
||||
|
||||
while ($current <= $end) {
|
||||
$months[] = $current->format('Y-m-01');
|
||||
$current->modify('+1 month');
|
||||
}
|
||||
return $months;
|
||||
}
|
||||
$months = get_month_range($project['startDate'], $project['endDate']);
|
||||
|
||||
// Initialize financial data structure
|
||||
foreach ($metrics as $metric) {
|
||||
foreach ($months as $month) {
|
||||
$financial_data[$metric][$month] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Financial Calculations ---
|
||||
|
||||
// 1. Fetch base monthly data
|
||||
$monthly_costs = [];
|
||||
$monthly_wip = [];
|
||||
|
||||
$forecast_stmt = $pdo->prepare("SELECT id FROM forecasting WHERE projectId = :pid ORDER BY versionNumber DESC LIMIT 1");
|
||||
$forecast_stmt->execute([':pid' => $project_id]);
|
||||
$latest_forecast = $forecast_stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($latest_forecast) {
|
||||
$fid = $latest_forecast['id'];
|
||||
|
||||
// Base Monthly Cost
|
||||
$cost_sql = "SELECT fa.month, SUM(fa.allocatedDays * (r.totalMonthlyCost / 20)) as cost FROM forecastAllocation fa JOIN roster r ON fa.rosterId = r.id WHERE fa.forecastingId = :fid GROUP BY fa.month";
|
||||
$cost_stmt = $pdo->prepare($cost_sql);
|
||||
$cost_stmt->execute([':fid' => $fid]);
|
||||
$monthly_costs = $cost_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// Base Monthly WIP
|
||||
$recoverability_decimal = $project['recoverability'] / 100;
|
||||
$wip_sql = "SELECT fa.month, SUM(fa.allocatedDays * (r.grossRevenue * (1 - :recoverability))) as wip FROM forecastAllocation fa JOIN roster r ON fa.rosterId = r.id WHERE fa.forecastingId = :fid GROUP BY fa.month";
|
||||
$wip_stmt = $pdo->prepare($wip_sql);
|
||||
$wip_stmt->execute([':fid' => $fid, ':recoverability' => $recoverability_decimal]);
|
||||
$monthly_wip = $wip_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
}
|
||||
|
||||
// Base Monthly Billings
|
||||
$billing_stmt = $pdo->prepare("SELECT month, amount FROM billingMonthly WHERE projectId = :pid");
|
||||
$billing_stmt->execute([':pid' => $project_id]);
|
||||
$monthly_billing = $billing_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// Base Monthly Expenses
|
||||
$expenses_stmt = $pdo->prepare("SELECT month, amount FROM expensesMonthly WHERE projectId = :pid");
|
||||
$expenses_stmt->execute([':pid' => $project_id]);
|
||||
$monthly_expenses = $expenses_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// 2. Calculate and process values month by month, including overrides
|
||||
$cumulative_billing = 0;
|
||||
$cumulative_cost = 0;
|
||||
$cumulative_expenses = 0;
|
||||
$previous_month_opening_balance = 0; // Initialize Opening Balance for the first month.
|
||||
|
||||
// Fetch all overridden data at once to avoid querying in a loop
|
||||
$override_stmt = $pdo->prepare("SELECT month, metricName, value FROM projectFinanceMonthly WHERE projectId = :pid AND is_overridden = 1");
|
||||
$override_stmt->execute([':pid' => $project_id]);
|
||||
$overridden_rows = $override_stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$overrides = [];
|
||||
$metric_name_map = [
|
||||
'wip' => 'WIP',
|
||||
'opening_balance' => 'Opening Balance',
|
||||
'billing' => 'Billings',
|
||||
'expenses' => 'Expenses',
|
||||
'cost' => 'Cost',
|
||||
'nsr' => 'NSR',
|
||||
'margin' => 'Margin'
|
||||
];
|
||||
foreach ($overridden_rows as $row) {
|
||||
if (isset($metric_name_map[$row['metricName']])) {
|
||||
$metric_display_name = $metric_name_map[$row['metricName']];
|
||||
$overrides[$row['month']][$metric_display_name] = $row['value'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($months as $month) {
|
||||
// --- Opening Balance ---
|
||||
// Opening Balance of the current month is the Opening Balance of the previous month.
|
||||
$opening_balance = $previous_month_opening_balance;
|
||||
$financial_data['Opening Balance'][$month] = $opening_balance;
|
||||
if (isset($overrides[$month]['Opening Balance'])) {
|
||||
$financial_data['Opening Balance'][$month] = $overrides[$month]['Opening Balance'];
|
||||
}
|
||||
|
||||
// --- Base Data ---
|
||||
$cost = $monthly_costs[$month] ?? 0;
|
||||
$base_monthly_wip = $monthly_wip[$month] ?? 0;
|
||||
$billing = $monthly_billing[$month] ?? 0;
|
||||
$expenses = $monthly_expenses[$month] ?? 0;
|
||||
|
||||
// --- Cumulative Metrics ---
|
||||
$cumulative_billing += $billing;
|
||||
$financial_data['Billings'][$month] = $cumulative_billing;
|
||||
if (isset($overrides[$month]['Billings'])) {
|
||||
$financial_data['Billings'][$month] = $overrides[$month]['Billings'];
|
||||
$cumulative_billing = $financial_data['Billings'][$month];
|
||||
}
|
||||
|
||||
$cumulative_cost += $cost;
|
||||
$financial_data['Cost'][$month] = $cumulative_cost;
|
||||
if (isset($overrides[$month]['Cost'])) {
|
||||
$financial_data['Cost'][$month] = $overrides[$month]['Cost'];
|
||||
$cumulative_cost = $financial_data['Cost'][$month];
|
||||
}
|
||||
|
||||
$cumulative_expenses += $expenses;
|
||||
$financial_data['Expenses'][$month] = $cumulative_expenses;
|
||||
if (isset($overrides[$month]['Expenses'])) {
|
||||
$financial_data['Expenses'][$month] = $overrides[$month]['Expenses'];
|
||||
$cumulative_expenses = $financial_data['Expenses'][$month];
|
||||
}
|
||||
|
||||
// --- WIP (Closing Balance) ---
|
||||
$final_opening_balance = $financial_data['Opening Balance'][$month];
|
||||
$current_wip = $final_opening_balance + $expenses + $base_monthly_wip - $billing;
|
||||
$financial_data['WIP'][$month] = $current_wip;
|
||||
if (isset($overrides[$month]['WIP'])) {
|
||||
$financial_data['WIP'][$month] = $overrides[$month]['WIP'];
|
||||
}
|
||||
|
||||
// --- Calculated Metrics (NSR and Margin) ---
|
||||
$nsr = $financial_data['WIP'][$month] + $financial_data['Billings'][$month] - $financial_data['Opening Balance'][$month] - $financial_data['Expenses'][$month];
|
||||
$financial_data['NSR'][$month] = $nsr;
|
||||
if (isset($overrides[$month]['NSR'])) {
|
||||
$financial_data['NSR'][$month] = $overrides[$month]['NSR'];
|
||||
}
|
||||
|
||||
$final_nsr = $financial_data['NSR'][$month];
|
||||
$final_cost = $financial_data['Cost'][$month];
|
||||
$margin = ($final_nsr != 0) ? (($final_nsr - $final_cost) / $final_nsr) : 0;
|
||||
$financial_data['Margin'][$month] = $margin;
|
||||
if (isset($overrides[$month]['Margin'])) {
|
||||
$financial_data['Margin'][$month] = $overrides[$month]['Margin'];
|
||||
}
|
||||
|
||||
// --- Carry-over for next iteration ---
|
||||
// The next month's opening balance is this month's final opening balance (including override).
|
||||
$previous_month_opening_balance = $financial_data['Opening Balance'][$month];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- PAGE RENDER ---
|
||||
if (!$project) {
|
||||
http_response_code(404);
|
||||
$page_title = "Project Not Found";
|
||||
} else {
|
||||
$page_title = "Details for " . htmlspecialchars($project['name']);
|
||||
}
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?php echo $page_title; ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-navbar">
|
||||
Project Financials
|
||||
</div>
|
||||
<div class="main-wrapper">
|
||||
<nav class="sidebar">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="roster.php"><i class="bi bi-people-fill me-2"></i>Roster</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="projects.php"><i class="bi bi-briefcase-fill me-2"></i>Projects</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<?php if (!$project): ?>
|
||||
<div class="alert alert-danger">Project with ID <?php echo htmlspecialchars($project_id); ?> not found.</div>
|
||||
<?php else: ?>
|
||||
<div class="page-header">
|
||||
<h1 class="h2">Project: <?php echo htmlspecialchars($project['name']); ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="billing.php?id=<?php echo $project['id']; ?>" class="btn btn-primary"><i class="bi bi-credit-card-fill me-2"></i>Billing</a>
|
||||
<a href="expenses.php?id=<?php echo $project['id']; ?>" class="btn btn-danger me-2"><i class="bi bi-wallet-fill me-2"></i>Expenses</a>
|
||||
<a href="forecasting.php?projectId=<?php echo $project['id']; ?>" class="btn btn-success"><i class="bi bi-bar-chart-line-fill me-2"></i>Forecasting</a>
|
||||
<a href="project_finance_details.php?id=<?php echo $project['id']; ?>" class="btn btn-info"><i class="bi bi-eye-fill me-2"></i>View Data</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Project Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Project Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Name</dt>
|
||||
<dd class="col-sm-8"><?php echo htmlspecialchars($project['name']); ?></dd>
|
||||
<dt class="col-sm-4">WBS</dt>
|
||||
<dd class="col-sm-8"><?php echo htmlspecialchars($project['wbs'] ?? 'N/A'); ?></dd>
|
||||
<dt class="col-sm-4">Start Date</dt>
|
||||
<dd class="col-sm-8"><?php echo htmlspecialchars($project['startDate']); ?></dd>
|
||||
<dt class="col-sm-4">End Date</dt>
|
||||
<dd class="col-sm-8"><?php echo htmlspecialchars($project['endDate']); ?></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Budget</dt>
|
||||
<dd class="col-sm-8">€<?php echo number_format($project['budget'], 2); ?></dd>
|
||||
<dt class="col-sm-4">Recoverability</dt>
|
||||
<dd class="col-sm-8"><?php echo number_format($project['recoverability'], 2); ?>%</dd>
|
||||
<dt class="col-sm-4">Target Margin</dt>
|
||||
<dd class="col-sm-8"><?php echo number_format($project['targetMargin'], 2); ?>%</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Monthly Financials -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">Monthly Financials</h5>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary disabled" disabled><i class="bi bi-upload me-2"></i>Import</button>
|
||||
<a href="export_project_finance.php?project_id=<?php echo $project['id']; ?>" class="btn btn-secondary"><i class="bi bi-download me-2"></i>Export</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead>
|
||||
<tr class="text-center">
|
||||
<th class="bg-body-tertiary" style="min-width: 150px;">Metric</th>
|
||||
<?php
|
||||
$is_first_month = true;
|
||||
foreach ($months as $month): ?>
|
||||
<th>
|
||||
<?php echo date('M Y', strtotime($month)); ?>
|
||||
</th>
|
||||
<?php
|
||||
$is_first_month = false;
|
||||
endforeach; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$metrics_to_id = ['WIP', 'Opening Balance', 'Billings', 'Expenses', 'Cost', 'NSR', 'Margin'];
|
||||
foreach ($metrics as $metric): ?>
|
||||
<tr>
|
||||
<td class="fw-bold bg-body-tertiary"><?php echo $metric; ?></td>
|
||||
<?php
|
||||
foreach ($months as $month):
|
||||
$cell_id_attr = '';
|
||||
if (in_array($metric, $metrics_to_id)) {
|
||||
$metric_id = str_replace(' ', '-', strtolower($metric));
|
||||
$month_id = date('Y-m', strtotime($month));
|
||||
$cell_id_attr = "id=\"{$metric_id}-{$month_id}\"";
|
||||
}
|
||||
?>
|
||||
<td class="text-end" <?php echo $cell_id_attr; ?> >
|
||||
<?php
|
||||
if ($metric === 'Margin') {
|
||||
echo number_format(($financial_data[$metric][$month] ?? 0) * 100, 2) . '%';
|
||||
} else {
|
||||
echo '€' . number_format($financial_data[$metric][$month] ?? 0, 2);
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<?php
|
||||
endforeach; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<tr>
|
||||
<td class="fw-bold bg-body-tertiary">Actions</td>
|
||||
<?php
|
||||
$override_status = [];
|
||||
$override_check_stmt = $pdo->prepare("SELECT month, is_overridden FROM projectFinanceMonthly WHERE projectId = :pid GROUP BY month, is_overridden");
|
||||
$override_check_stmt->execute([':pid' => $project_id]);
|
||||
$statuses = $override_check_stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$monthly_override_status = [];
|
||||
foreach($months as $month){
|
||||
$monthly_override_status[$month] = 0;
|
||||
}
|
||||
foreach($statuses as $status){
|
||||
if($status['is_overridden']){
|
||||
$monthly_override_status[$status['month']] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
$first_unoverridden_month = null;
|
||||
foreach($monthly_override_status as $month => $status){
|
||||
if(!$status){
|
||||
$first_unoverridden_month = $month;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($months as $month):
|
||||
?>
|
||||
<td class="text-center">
|
||||
<?php
|
||||
if ($monthly_override_status[$month]) {
|
||||
echo '<span class="badge bg-success">Overridden</span>';
|
||||
} else {
|
||||
$display_style = ($month === $first_unoverridden_month) ? '' : 'style="display: none;"';
|
||||
echo '<button class="btn btn-sm btn-outline-primary btn-override" data-month="' . date('Y-m', strtotime($month)) . '" id="override-btn-' . $month . '" ' . $display_style . ' data-project-id="' . $project_id . '">Override</button>';
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="assets/js/project_details.js?v=<?php echo time(); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
147
project_finance_details.php
Normal file
147
project_finance_details.php
Normal file
@ -0,0 +1,147 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$project_id = $_GET['id'] ?? null;
|
||||
$project_name = '';
|
||||
$financial_data = [];
|
||||
|
||||
if ($project_id) {
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Fetch project name
|
||||
$stmt = $pdo->prepare("SELECT name FROM projects WHERE id = :id");
|
||||
$stmt->execute([':id' => $project_id]);
|
||||
$project_name = $stmt->fetchColumn();
|
||||
|
||||
// Fetch financial data
|
||||
$stmt = $pdo->prepare("SELECT * FROM projectFinanceMonthly WHERE projectId = :pid ORDER BY month");
|
||||
$stmt->execute([':pid' => $project_id]);
|
||||
$financial_data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$db_error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$page_title = "Project ID not provided";
|
||||
}
|
||||
|
||||
$page_title = $project_name ? "Financial Data for " . htmlspecialchars($project_name) : "Project Not Found";
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?php echo $page_title; ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-navbar">
|
||||
Project Financials
|
||||
</div>
|
||||
<div class="main-wrapper">
|
||||
<nav class="sidebar">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link" href="roster.php"><i class="bi bi-people-fill me-2"></i>Roster</a></li>
|
||||
<li class="nav-item"><a class="nav-link active" href="projects.php"><i class="bi bi-briefcase-fill me-2"></i>Projects</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<div class="page-header">
|
||||
<h1 class="h2"><?php echo $page_title; ?></h1>
|
||||
<div class="header-actions">
|
||||
<a href="project_details.php?id=<?php echo htmlspecialchars($project_id); ?>" class="btn btn-secondary"><i class="bi bi-arrow-left-circle-fill me-2"></i>Back to Project</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<?php if (isset($db_error)): ?>
|
||||
<div class="alert alert-danger"><?php echo $db_error; ?></div>
|
||||
<?php elseif (!$project_id || !$project_name): ?>
|
||||
<div class="alert alert-warning">Project not found or no ID provided.</div>
|
||||
<?php elseif (empty($financial_data)): ?>
|
||||
<div class="alert alert-info">No financial data found for this project.</div>
|
||||
<?php else: ?>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<?php
|
||||
// Define user-friendly headers
|
||||
$header_map = [
|
||||
'month' => 'Month',
|
||||
'wip' => 'WIP',
|
||||
'opening_balance' => 'Opening Balance',
|
||||
'billing' => 'Billing',
|
||||
'expenses' => 'Expenses',
|
||||
'is_overridden' => 'Overridden',
|
||||
'createdAt' => 'Created At',
|
||||
'updatedAt' => 'Updated At',
|
||||
'id' => 'ID',
|
||||
];
|
||||
|
||||
// Get the headers from the first row, but exclude projectId
|
||||
$headers = [];
|
||||
if (!empty($financial_data)) {
|
||||
$headers = array_keys($financial_data[0]);
|
||||
// Remove projectId from headers
|
||||
$headers = array_filter($headers, fn($h) => $h !== 'projectId');
|
||||
}
|
||||
|
||||
foreach ($headers as $key) {
|
||||
$display_header = isset($header_map[$key]) ? $header_map[$key] : htmlspecialchars($key);
|
||||
echo "<th>" . $display_header . "</th>";
|
||||
}
|
||||
?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($financial_data as $row): ?>
|
||||
<tr>
|
||||
<?php
|
||||
foreach ($headers as $key) {
|
||||
$value = $row[$key];
|
||||
$formatted_value = htmlspecialchars($value);
|
||||
|
||||
// Simple currency/date formatting
|
||||
if (in_array($key, ['opening_balance', 'payment', 'wip', 'expenses', 'cost', 'nsr', 'value'])) {
|
||||
$formatted_value = '€' . number_format((float)$value, 2);
|
||||
} elseif ($key === 'margin') {
|
||||
$formatted_value = number_format((float)$value * 100, 2) . '%';
|
||||
} elseif (in_array($key, ['is_confirmed', 'is_overridden'])) {
|
||||
$formatted_value = $value ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-x-circle-fill text-danger"></i>';
|
||||
} elseif ($key === 'month') {
|
||||
try {
|
||||
$date = new DateTime($value);
|
||||
$formatted_value = $date->format('F Y');
|
||||
} catch (Exception $e) {
|
||||
// keep original value if format fails
|
||||
}
|
||||
}
|
||||
|
||||
echo "<td>" . $formatted_value . "</td>";
|
||||
}
|
||||
?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
341
projects.php
Normal file
341
projects.php
Normal file
@ -0,0 +1,341 @@
|
||||
<?php
|
||||
// --- FORM PROCESSING ---
|
||||
$form_error = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_project') {
|
||||
if (empty($_POST['name'])) {
|
||||
$form_error = "Project Name is required.";
|
||||
} else {
|
||||
try {
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
$pdo_form = db();
|
||||
$insert_sql = "INSERT INTO projects (name, wbs, startDate, endDate, budget, recoverability, targetMargin) VALUES (:name, :wbs, :startDate, :endDate, :budget, :recoverability, :targetMargin)";
|
||||
$stmt = $pdo_form->prepare($insert_sql);
|
||||
$stmt->execute([
|
||||
':name' => $_POST['name'],
|
||||
':wbs' => $_POST['wbs'] ?? null,
|
||||
':startDate' => empty($_POST['startDate']) ? null : $_POST['startDate'],
|
||||
':endDate' => empty($_POST['endDate']) ? null : $_POST['endDate'],
|
||||
':budget' => (float)($_POST['budget'] ?? 0),
|
||||
':recoverability' => (float)($_POST['recoverability'] ?? 100),
|
||||
':targetMargin' => (float)($_POST['targetMargin'] ?? 0),
|
||||
]);
|
||||
|
||||
// Create initial forecasting version
|
||||
$projectId = $pdo_form->lastInsertId();
|
||||
$forecast_sql = "INSERT INTO forecasting (projectId, versionNumber, createdAt) VALUES (:projectId, 1, NOW())";
|
||||
$forecast_stmt = $pdo_form->prepare($forecast_sql);
|
||||
$forecast_stmt->execute([':projectId' => $projectId]);
|
||||
|
||||
header("Location: " . $_SERVER['PHP_SELF']);
|
||||
exit();
|
||||
} catch (PDOException $e) {
|
||||
$form_error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'delete_project') {
|
||||
try {
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
$pdo_delete = db();
|
||||
$delete_sql = "DELETE FROM projects WHERE id = :id";
|
||||
$stmt = $pdo_delete->prepare($delete_sql);
|
||||
$stmt->execute([':id' => $_POST['id']]);
|
||||
header("Location: " . $_SERVER['PHP_SELF']);
|
||||
exit();
|
||||
} catch (PDOException $e) {
|
||||
$form_error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'update_project') {
|
||||
if (empty($_POST['id']) || empty($_POST['name'])) {
|
||||
$form_error = "ID and Project Name are required for an update.";
|
||||
} else {
|
||||
try {
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
$pdo_update = db();
|
||||
$update_sql = "UPDATE projects SET
|
||||
name = :name,
|
||||
wbs = :wbs,
|
||||
startDate = :startDate,
|
||||
endDate = :endDate,
|
||||
budget = :budget,
|
||||
recoverability = :recoverability,
|
||||
targetMargin = :targetMargin
|
||||
WHERE id = :id";
|
||||
|
||||
$stmt = $pdo_update->prepare($update_sql);
|
||||
$stmt->execute([
|
||||
':id' => $_POST['id'],
|
||||
':name' => $_POST['name'],
|
||||
':wbs' => $_POST['wbs'] ?? null,
|
||||
':startDate' => empty($_POST['startDate']) ? null : $_POST['startDate'],
|
||||
':endDate' => empty($_POST['endDate']) ? null : $_POST['endDate'],
|
||||
':budget' => (float)($_POST['budget'] ?? 0),
|
||||
':recoverability' => (float)($_POST['recoverability'] ?? 100),
|
||||
':targetMargin' => (float)($_POST['targetMargin'] ?? 0),
|
||||
]);
|
||||
|
||||
header("Location: " . $_SERVER['PHP_SELF']);
|
||||
exit();
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$form_error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DATABASE INITIALIZATION ---
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
function execute_sql_from_file($pdo, $filepath) {
|
||||
try {
|
||||
$sql = file_get_contents($filepath);
|
||||
$pdo->exec($sql);
|
||||
return true;
|
||||
} catch (PDOException $e) {
|
||||
if (strpos($e->getMessage(), 'already exists') === false) {
|
||||
error_log("SQL Execution Error: " . $e->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$projects_data = [];
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Apply all migrations
|
||||
$migration_files = glob(__DIR__ . '/db/migrations/*.sql');
|
||||
sort($migration_files);
|
||||
foreach ($migration_files as $file) {
|
||||
execute_sql_from_file($pdo, $file);
|
||||
}
|
||||
|
||||
$stmt = $pdo->query("SELECT * FROM projects ORDER BY name");
|
||||
$projects_data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
$db_error = "Database connection failed: " . $e->getMessage();
|
||||
}
|
||||
|
||||
// --- RENDER PAGE ---
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Projects - Project Financials</title>
|
||||
|
||||
<meta name="description" content="Manage Projects - Project Financials Management Tool">
|
||||
<meta property="og:title" content="Projects - Project Financials">
|
||||
<meta property="og:description" content="Manage your project financials, roster, and budget.">
|
||||
<meta property="og:image" content="<?php echo htmlspecialchars($_SERVER['PROJECT_IMAGE_URL'] ?? ''); ?>">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-navbar">
|
||||
Project Financials
|
||||
</div>
|
||||
<div class="main-wrapper">
|
||||
<nav class="sidebar">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="roster.php"><i class="bi bi-people-fill me-2"></i>Roster</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="projects.php"><i class="bi bi-briefcase-fill me-2"></i>Projects</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<?php if (isset($db_error)): ?>
|
||||
<div class="alert alert-danger"><?php echo htmlspecialchars($db_error); ?></div>
|
||||
<?php elseif (isset($form_error)): ?>
|
||||
<div class="alert alert-danger"><?php echo htmlspecialchars($form_error); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="h2">Projects</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary disabled" disabled><i class="bi bi-upload me-2"></i>Import Excel</button>
|
||||
<button class="btn btn-secondary disabled" disabled><i class="bi bi-download me-2"></i>Export Excel</button>
|
||||
<button class="btn btn-primary" id="newProjectBtn"><i class="bi bi-plus-circle-fill me-2"></i>New Project</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>WBS</th>
|
||||
<th>Start Date</th>
|
||||
<th>End Date</th>
|
||||
<th>Budget</th>
|
||||
<th>Recoverability</th>
|
||||
<th>Target Margin</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($projects_data)): ?>
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-secondary">No projects found.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($projects_data as $row): ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($row['name']); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['wbs']); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['startDate']); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['endDate']); ?></td>
|
||||
<td>€<?php echo number_format($row['budget'], 2); ?></td>
|
||||
<td><?php echo number_format($row['recoverability'], 2); ?>%</td>
|
||||
<td><?php echo number_format($row['targetMargin'], 2); ?>%</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<a href="project_details.php?id=<?php echo $row['id']; ?>" class="btn btn-sm btn-outline-info me-2">View</a>
|
||||
<button class="btn btn-sm btn-outline-primary me-2 edit-btn"
|
||||
data-row='<?php echo htmlspecialchars(json_encode($row), ENT_QUOTES, 'UTF-8'); ?>'>
|
||||
Edit
|
||||
</button>
|
||||
<form action="projects.php" method="POST" onsubmit="return confirm('Are you sure you want to delete this item?');">
|
||||
<input type="hidden" name="action" value="delete_project">
|
||||
<input type="hidden" name="id" value="<?php echo $row['id']; ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- New Project Modal -->
|
||||
<div class="modal fade" id="newProjectModal" tabindex="-1" aria-labelledby="newProjectModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form action="projects.php" method="POST">
|
||||
<input type="hidden" name="action" value="create_project">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="newProjectModalLabel">New Project</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Project Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="wbs" class="form-label">WBS</label>
|
||||
<input type="text" class="form-control" id="wbs" name="wbs">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="startDate" class="form-label">Start Date</label>
|
||||
<input type="date" class="form-control" id="startDate" name="startDate">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="endDate" class="form-label">End Date</label>
|
||||
<input type="date" class="form-control" id="endDate" name="endDate">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="budget" class="form-label">Budget (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="budget" name="budget" value="0">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="recoverability" class="form-label">Recoverability (%)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="recoverability" name="recoverability" value="100">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="targetMargin" class="form-label">Target Margin (%)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="targetMargin" name="targetMargin" value="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">Save Project</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="assets/js/projects.js?v=<?php echo time(); ?>"></script>
|
||||
|
||||
<!-- Edit Project Modal -->
|
||||
<div class="modal fade" id="editProjectModal" tabindex="-1" aria-labelledby="editProjectModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form action="projects.php" method="POST">
|
||||
<input type="hidden" name="action" value="update_project">
|
||||
<input type="hidden" name="id" id="edit-id">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editProjectModalLabel">Edit Project</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="edit-name" class="form-label">Project Name</label>
|
||||
<input type="text" class="form-control" id="edit-name" name="name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit-wbs" class="form-label">WBS</label>
|
||||
<input type="text" class="form-control" id="edit-wbs" name="wbs">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-startDate" class="form-label">Start Date</label>
|
||||
<input type="date" class="form-control" id="edit-startDate" name="startDate">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-endDate" class="form-label">End Date</label>
|
||||
<input type="date" class="form-control" id="edit-endDate" name="endDate">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit-budget" class="form-label">Budget (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="edit-budget" name="budget">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-recoverability" class="form-label">Recoverability (%)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="edit-recoverability" name="recoverability">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-targetMargin" class="form-label">Target Margin (%)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="edit-targetMargin" name="targetMargin">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
742
roster.php
Normal file
742
roster.php
Normal file
@ -0,0 +1,742 @@
|
||||
<?php
|
||||
// --- FORM PROCESSING ---
|
||||
$form_error = null;
|
||||
$form_success = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_roster') {
|
||||
// Basic validation
|
||||
if (empty($_POST['sapCode']) || empty($_POST['fullNameEn'])) {
|
||||
$form_error = "SAP Code and Full Name are required.";
|
||||
} else {
|
||||
try {
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
$pdo_form = db();
|
||||
|
||||
// Prepare data from form
|
||||
$newAmendedSalary = (float)($_POST['newAmendedSalary'] ?? 0);
|
||||
$employerContributions = (float)($_POST['employerContributions'] ?? 0);
|
||||
$cars = (float)($_POST['cars'] ?? 0);
|
||||
$ticketRestaurant = (float)($_POST['ticketRestaurant'] ?? 0);
|
||||
$metlife = (float)($_POST['metlife'] ?? 0);
|
||||
$topusPerMonth = (float)($_POST['topusPerMonth'] ?? 0);
|
||||
$grossRevenue = (float)($_POST['grossRevenue'] ?? 0);
|
||||
|
||||
// Auto-calculations
|
||||
$totalSalaryCostWithLabor = $newAmendedSalary + $employerContributions;
|
||||
$totalMonthlyCost = $totalSalaryCostWithLabor + $cars + $ticketRestaurant + $metlife + $topusPerMonth;
|
||||
$totalAnnualCost = $totalMonthlyCost * 14;
|
||||
|
||||
$insert_sql = "INSERT INTO roster (sapCode, fullNameEn, legalEntity, functionBusinessUnit, costCenterCode, `level`, newAmendedSalary, employerContributions, cars, ticketRestaurant, metlife, topusPerMonth, totalSalaryCostWithLabor, totalMonthlyCost, totalAnnualCost, grossRevenue) VALUES (:sapCode, :fullNameEn, :legalEntity, :functionBusinessUnit, :costCenterCode, :level, :newAmendedSalary, :employerContributions, :cars, :ticketRestaurant, :metlife, :topusPerMonth, :totalSalaryCostWithLabor, :totalMonthlyCost, :totalAnnualCost, :grossRevenue)";
|
||||
$stmt = $pdo_form->prepare($insert_sql);
|
||||
|
||||
$stmt->execute([
|
||||
':sapCode' => $_POST['sapCode'],
|
||||
':fullNameEn' => $_POST['fullNameEn'],
|
||||
':legalEntity' => $_POST['legalEntity'] ?? null,
|
||||
':functionBusinessUnit' => $_POST['functionBusinessUnit'] ?? null,
|
||||
':costCenterCode' => $_POST['costCenterCode'] ?? null,
|
||||
':level' => $_POST['level'] ?? null,
|
||||
':newAmendedSalary' => $newAmendedSalary,
|
||||
':employerContributions' => $employerContributions,
|
||||
':cars' => $cars,
|
||||
':ticketRestaurant' => $ticketRestaurant,
|
||||
':metlife' => $metlife,
|
||||
':topusPerMonth' => $topusPerMonth,
|
||||
':totalSalaryCostWithLabor' => $totalSalaryCostWithLabor,
|
||||
':totalMonthlyCost' => $totalMonthlyCost,
|
||||
':totalAnnualCost' => $totalAnnualCost,
|
||||
':grossRevenue' => $grossRevenue
|
||||
]);
|
||||
|
||||
// To prevent form resubmission on refresh, redirect
|
||||
header("Location: " . $_SERVER['PHP_SELF']);
|
||||
exit();
|
||||
|
||||
} catch (PDOException $e) {
|
||||
// Check for duplicate entry
|
||||
if ($e->errorInfo[1] == 1062) {
|
||||
$form_error = "Error: A resource with this SAP Code already exists.";
|
||||
} else {
|
||||
$form_error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'delete_roster') {
|
||||
try {
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
$pdo_delete = db();
|
||||
$delete_sql = "DELETE FROM roster WHERE id = :id";
|
||||
$stmt = $pdo_delete->prepare($delete_sql);
|
||||
$stmt->execute([':id' => $_POST['id']]);
|
||||
header("Location: " . $_SERVER['PHP_SELF']);
|
||||
exit();
|
||||
} catch (PDOException $e) {
|
||||
$form_error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'update_roster') {
|
||||
if (empty($_POST['id']) || empty($_POST['sapCode']) || empty($_POST['fullNameEn'])) {
|
||||
$form_error = "ID, SAP Code, and Full Name are required for an update.";
|
||||
} else {
|
||||
try {
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
$pdo_update = db();
|
||||
|
||||
// Prepare data from form
|
||||
$newAmendedSalary = (float)($_POST['newAmendedSalary'] ?? 0);
|
||||
$employerContributions = (float)($_POST['employerContributions'] ?? 0);
|
||||
$cars = (float)($_POST['cars'] ?? 0);
|
||||
$ticketRestaurant = (float)($_POST['ticketRestaurant'] ?? 0);
|
||||
$metlife = (float)($_POST['metlife'] ?? 0);
|
||||
$topusPerMonth = (float)($_POST['topusPerMonth'] ?? 0);
|
||||
$grossRevenue = (float)($_POST['grossRevenue'] ?? 0);
|
||||
|
||||
// Auto-calculations
|
||||
$totalSalaryCostWithLabor = $newAmendedSalary + $employerContributions;
|
||||
$totalMonthlyCost = $totalSalaryCostWithLabor + $cars + $ticketRestaurant + $metlife + $topusPerMonth;
|
||||
$totalAnnualCost = $totalMonthlyCost * 14;
|
||||
|
||||
$update_sql = "UPDATE roster SET
|
||||
sapCode = :sapCode,
|
||||
fullNameEn = :fullNameEn,
|
||||
legalEntity = :legalEntity,
|
||||
functionBusinessUnit = :functionBusinessUnit,
|
||||
costCenterCode = :costCenterCode,
|
||||
`level` = :level,
|
||||
newAmendedSalary = :newAmendedSalary,
|
||||
employerContributions = :employerContributions,
|
||||
cars = :cars,
|
||||
ticketRestaurant = :ticketRestaurant,
|
||||
metlife = :metlife,
|
||||
topusPerMonth = :topusPerMonth,
|
||||
totalSalaryCostWithLabor = :totalSalaryCostWithLabor,
|
||||
totalMonthlyCost = :totalMonthlyCost,
|
||||
totalAnnualCost = :totalAnnualCost,
|
||||
grossRevenue = :grossRevenue
|
||||
WHERE id = :id";
|
||||
|
||||
$stmt = $pdo_update->prepare($update_sql);
|
||||
$stmt->execute([
|
||||
':id' => $_POST['id'],
|
||||
':sapCode' => $_POST['sapCode'],
|
||||
':fullNameEn' => $_POST['fullNameEn'],
|
||||
':legalEntity' => $_POST['legalEntity'] ?? null,
|
||||
':functionBusinessUnit' => $_POST['functionBusinessUnit'] ?? null,
|
||||
':costCenterCode' => $_POST['costCenterCode'] ?? null,
|
||||
':level' => $_POST['level'] ?? null,
|
||||
':newAmendedSalary' => $newAmendedSalary,
|
||||
':employerContributions' => $employerContributions,
|
||||
':cars' => $cars,
|
||||
':ticketRestaurant' => $ticketRestaurant,
|
||||
':metlife' => $metlife,
|
||||
':topusPerMonth' => $topusPerMonth,
|
||||
':totalSalaryCostWithLabor' => $totalSalaryCostWithLabor,
|
||||
':totalMonthlyCost' => $totalMonthlyCost,
|
||||
':totalAnnualCost' => $totalAnnualCost,
|
||||
':grossRevenue' => $grossRevenue
|
||||
]);
|
||||
|
||||
header("Location: " . $_SERVER['PHP_SELF']);
|
||||
exit();
|
||||
|
||||
} catch (PDOException $e) {
|
||||
if ($e->errorInfo[1] == 1062) {
|
||||
$form_error = "Error: A resource with this SAP Code already exists.";
|
||||
} else {
|
||||
$form_error = "Database error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DATABASE INITIALIZATION ---
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
function execute_sql_from_file($pdo, $filepath) {
|
||||
try {
|
||||
$sql = file_get_contents($filepath);
|
||||
$pdo->exec($sql);
|
||||
return true;
|
||||
} catch (PDOException $e) {
|
||||
if (strpos($e->getMessage(), 'already exists') === false) {
|
||||
error_log("SQL Execution Error: " . $e->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function seed_roster_data($pdo) {
|
||||
try {
|
||||
$stmt = $pdo->query("SELECT COUNT(*) FROM roster");
|
||||
if ($stmt->fetchColumn() > 0) {
|
||||
return; // Data already exists
|
||||
}
|
||||
|
||||
$seed_data = [
|
||||
[
|
||||
'sapCode' => '1001', 'fullNameEn' => 'John Doe', 'legalEntity' => 'Entity A', 'functionBusinessUnit' => 'Finance',
|
||||
'costCenterCode' => 'CC100', 'level' => 'Senior', 'newAmendedSalary' => 6000, 'employerContributions' => 1500,
|
||||
'cars' => 500, 'ticketRestaurant' => 150, 'metlife' => 50, 'topusPerMonth' => 100
|
||||
],
|
||||
[
|
||||
'sapCode' => '1002', 'fullNameEn' => 'Jane Smith', 'legalEntity' => 'Entity B', 'functionBusinessUnit' => 'IT',
|
||||
'costCenterCode' => 'CC200', 'level' => 'Manager', 'newAmendedSalary' => 8000, 'employerContributions' => 2000,
|
||||
'cars' => 600, 'ticketRestaurant' => 150, 'metlife' => 60, 'topusPerMonth' => 120
|
||||
],
|
||||
];
|
||||
|
||||
$insert_sql = "INSERT INTO roster (sapCode, fullNameEn, legalEntity, functionBusinessUnit, costCenterCode, `level`, newAmendedSalary, employerContributions, cars, ticketRestaurant, metlife, topusPerMonth, totalSalaryCostWithLabor, totalMonthlyCost, totalAnnualCost) VALUES (:sapCode, :fullNameEn, :legalEntity, :functionBusinessUnit, :costCenterCode, :level, :newAmendedSalary, :employerContributions, :cars, :ticketRestaurant, :metlife, :topusPerMonth, :totalSalaryCostWithLabor, :totalMonthlyCost, :totalAnnualCost)";
|
||||
$stmt = $pdo->prepare($insert_sql);
|
||||
|
||||
foreach ($seed_data as $row) {
|
||||
$totalSalaryCostWithLabor = $row['newAmendedSalary'] + $row['employerContributions'];
|
||||
$totalMonthlyCost = $totalSalaryCostWithLabor + $row['cars'] + $row['ticketRestaurant'] + $row['metlife'] + $row['topusPerMonth'];
|
||||
$totalAnnualCost = $totalMonthlyCost * 14;
|
||||
|
||||
$stmt->execute([
|
||||
':sapCode' => $row['sapCode'],
|
||||
':fullNameEn' => $row['fullNameEn'],
|
||||
':legalEntity' => $row['legalEntity'],
|
||||
':functionBusinessUnit' => $row['functionBusinessUnit'],
|
||||
':costCenterCode' => $row['costCenterCode'],
|
||||
':level' => $row['level'],
|
||||
':newAmendedSalary' => $row['newAmendedSalary'],
|
||||
':employerContributions' => $row['employerContributions'],
|
||||
':cars' => $row['cars'],
|
||||
':ticketRestaurant' => $row['ticketRestaurant'],
|
||||
':metlife' => $row['metlife'],
|
||||
':topusPerMonth' => $row['topusPerMonth'],
|
||||
':totalSalaryCostWithLabor' => $totalSalaryCostWithLabor,
|
||||
':totalMonthlyCost' => $totalMonthlyCost,
|
||||
':totalAnnualCost' => $totalAnnualCost
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("Seeding Error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$roster_data = [];
|
||||
$search_term = $_GET['search'] ?? '';
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Apply all migrations
|
||||
$migration_files = glob(__DIR__ . '/db/migrations/*.sql');
|
||||
sort($migration_files);
|
||||
foreach ($migration_files as $file) {
|
||||
execute_sql_from_file($pdo, $file);
|
||||
}
|
||||
|
||||
seed_roster_data($pdo);
|
||||
|
||||
$sql = "SELECT * FROM roster";
|
||||
$params = [];
|
||||
|
||||
if (!empty($search_term)) {
|
||||
$sql .= " WHERE fullNameEn LIKE :search OR sapCode LIKE :search";
|
||||
$params[':search'] = '%' . $search_term . '%';
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY fullNameEn";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$roster_data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$db_error = "Database connection failed: " . $e->getMessage();
|
||||
}
|
||||
|
||||
// --- RENDER PAGE ---
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Financials</title>
|
||||
|
||||
<meta name="description" content="<?php echo htmlspecialchars($_SERVER['PROJECT_DESCRIPTION'] ?? 'Project Financials Management Tool'); ?>">
|
||||
<meta property="og:title" content="Project Financials">
|
||||
<meta property="og:description" content="<?php echo htmlspecialchars($_SERVER['PROJECT_DESCRIPTION'] ?? 'Manage your project financials, roster, and budget.'); ?>">
|
||||
<meta property="og:image" content="<?php echo htmlspecialchars($_SERVER['PROJECT_IMAGE_URL'] ?? ''); ?>">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-navbar">
|
||||
Project Financials
|
||||
</div>
|
||||
<div class="main-wrapper">
|
||||
<nav class="sidebar">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="roster.php"><i class="bi bi-people-fill me-2"></i>Roster</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="projects.php"><i class="bi bi-briefcase-fill me-2"></i>Projects</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<?php
|
||||
if (isset($_GET['import_status'])) {
|
||||
$status = $_GET['import_status'];
|
||||
$message = htmlspecialchars($_GET['import_message'] ?? '');
|
||||
$alert_class = $status === 'success' ? 'alert-success' : 'alert-danger';
|
||||
echo "<div class='alert {$alert_class} alert-dismissible fade show' role='alert'>
|
||||
{$message}
|
||||
<button type='button' class='btn-close' data-bs-dismiss='alert' aria-label='Close'></button>
|
||||
</div>";
|
||||
}
|
||||
?>
|
||||
<?php if (isset($db_error)): ?>
|
||||
<div class="alert alert-danger"><?php echo htmlspecialchars($db_error); ?></div>
|
||||
<?php elseif (isset($form_error)): ?>
|
||||
<div class="alert alert-danger"><?php echo htmlspecialchars($form_error); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="page-header">
|
||||
<h1 class="h2">Roster</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload me-2"></i>Import Excel</button>
|
||||
<a href="export.php" class="btn btn-secondary"><i class="bi bi-download me-2"></i>Export Excel</a>
|
||||
<button class="btn btn-primary" id="newResourceBtn"><i class="bi bi-plus-circle-fill me-2"></i>New Resource</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<form action="roster.php" method="GET" class="row g-3 align-items-center">
|
||||
<div class="col-auto">
|
||||
<label for="search" class="visually-hidden">Search</label>
|
||||
<input type="text" class="form-control" id="search" name="search" placeholder="Search by name or SAP..." value="<?php echo htmlspecialchars($_GET['search'] ?? ''); ?>">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
<a href="roster.php" class="btn btn-secondary">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SAP Code</th>
|
||||
<th>Full Name</th>
|
||||
<th>Level</th>
|
||||
<th>Legal Entity</th>
|
||||
<th>Function</th>
|
||||
<th>Cost Center</th>
|
||||
<th>Salary (€)</th>
|
||||
<th>Employer Contributions (€)</th>
|
||||
<th>Total Salary Cost (€)</th>
|
||||
<th>Cars (€)</th>
|
||||
<th>Metlife (€)</th>
|
||||
<th>Ticket Restaurant (€)</th>
|
||||
<th>Bonus/Month (€)</th>
|
||||
<th>Daily Cost (€)</th>
|
||||
<th>Total Monthly Cost (€)</th>
|
||||
<th>Total Annual Cost (€)</th>
|
||||
<th>Gross Revenue (€)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($roster_data)): ?>
|
||||
<tr>
|
||||
<td colspan="19" class="text-center text-secondary">No roster data found.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($roster_data as $row): ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($row['sapCode']); ?></td>
|
||||
<td>
|
||||
<a href="#" class="view-btn" data-bs-toggle="modal" data-bs-target="#viewResourceModal" data-row='<?php echo htmlspecialchars(json_encode($row), ENT_QUOTES, 'UTF-8'); ?>'>
|
||||
<?php echo htmlspecialchars($row['fullNameEn']); ?>
|
||||
</a>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($row['level']); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['legalEntity']); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['functionBusinessUnit']); ?></td>
|
||||
<td><?php echo htmlspecialchars($row['costCenterCode']); ?></td>
|
||||
<td>€<?php echo number_format($row['newAmendedSalary'], 2); ?></td>
|
||||
<td>€<?php echo number_format($row['employerContributions'], 2); ?></td>
|
||||
<td>€<?php echo number_format($row['totalSalaryCostWithLabor'], 2); ?></td>
|
||||
<td>€<?php echo number_format($row['cars'], 2); ?></td>
|
||||
<td>€<?php echo number_format($row['metlife'], 2); ?></td>
|
||||
<td>€<?php echo number_format($row['ticketRestaurant'], 2); ?></td>
|
||||
<td>€<?php echo number_format($row['topusPerMonth'], 2); ?></td>
|
||||
<td>€<?php echo number_format($row['totalMonthlyCost'] / 20, 2); ?></td>
|
||||
<td>€<?php echo number_format($row['totalMonthlyCost'], 2); ?></td>
|
||||
<td>€<?php echo number_format($row['totalAnnualCost'], 2); ?></td>
|
||||
<td>€<?php echo number_format($row['grossRevenue'], 2); ?></td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<button class="btn btn-sm btn-outline-info me-2 view-btn"
|
||||
data-row='<?php echo htmlspecialchars(json_encode($row), ENT_QUOTES, 'UTF-8'); ?>'>
|
||||
View
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary me-2 edit-btn"
|
||||
data-row='<?php echo htmlspecialchars(json_encode($row), ENT_QUOTES, 'UTF-8'); ?>'>
|
||||
Edit
|
||||
</button>
|
||||
<form action="roster.php" method="POST" onsubmit="return confirm('Are you sure you want to delete this item?');">
|
||||
<input type="hidden" name="action" value="delete_roster">
|
||||
<input type="hidden" name="id" value="<?php echo $row['id']; ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- New Resource Modal -->
|
||||
<div class="modal fade" id="newResourceModal" tabindex="-1" aria-labelledby="newResourceModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<form action="roster.php" method="POST">
|
||||
<input type="hidden" name="action" value="create_roster">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="newResourceModalLabel">New Resource</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 class="mb-3">Employee Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="sapCode" class="form-label">SAP Code</label>
|
||||
<input type="text" class="form-control" id="sapCode" name="sapCode" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="fullNameEn" class="form-label">Full Name</label>
|
||||
<input type="text" class="form-control" id="fullNameEn" name="fullNameEn" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="level" class="form-label">Level</label>
|
||||
<select class="form-select" id="level" name="level">
|
||||
<option value="">Select Level</option>
|
||||
<option value="BUSINESS ANALYST">BUSINESS ANALYST</option>
|
||||
<option value="CONSULTANT">CONSULTANT</option>
|
||||
<option value="SENIOR CONSULTANT">SENIOR CONSULTANT</option>
|
||||
<option value="ASSISTANT MANAGER">ASSISTANT MANAGER</option>
|
||||
<option value="MANAGER">MANAGER</option>
|
||||
<option value="SENIOR MANAGER">SENIOR MANAGER</option>
|
||||
<option value="Intern">Intern</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="legalEntity" class="form-label">Legal Entity</label>
|
||||
<input type="text" class="form-control" id="legalEntity" name="legalEntity">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="functionBusinessUnit" class="form-label">Function Business Unit</label>
|
||||
<input type="text" class="form-control" id="functionBusinessUnit" name="functionBusinessUnit">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="costCenterCode" class="form-label">Cost Center Code</label>
|
||||
<input type="text" class="form-control" id="costCenterCode" name="costCenterCode">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="my-3">Compensation Details</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="newAmendedSalary" class="form-label">Salary (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="newAmendedSalary" name="newAmendedSalary" value="0">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="employerContributions" class="form-label">Employer Contributions (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="employerContributions" name="employerContributions" value="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="my-3">Benefits & Allowances</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="cars" class="form-label">Cars (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="cars" name="cars" value="0">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="metlife" class="form-label">Metlife (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="metlife" name="metlife" value="0">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="ticketRestaurant" class="form-label">Ticket Restaurant (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="ticketRestaurant" name="ticketRestaurant" value="0">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="topusPerMonth" class="form-label">Bonus/Month (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="topusPerMonth" name="topusPerMonth" value="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="my-3">Revenue Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="grossRevenue" class="form-label">Gross Revenue (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="grossRevenue" name="grossRevenue" value="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">Save Resource</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
|
||||
|
||||
<!-- Edit Resource Modal -->
|
||||
<div class="modal fade" id="editResourceModal" tabindex="-1" aria-labelledby="editResourceModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<form action="roster.php" method="POST">
|
||||
<input type="hidden" name="action" value="update_roster">
|
||||
<input type="hidden" name="id" id="edit-id">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editResourceModalLabel">Edit Resource</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 class="mb-3">Employee Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-sapCode" class="form-label">SAP Code</label>
|
||||
<input type="text" class="form-control" id="edit-sapCode" name="sapCode" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-fullNameEn" class="form-label">Full Name</label>
|
||||
<input type="text" class="form-control" id="edit-fullNameEn" name="fullNameEn" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-level" class="form-label">Level</label>
|
||||
<select class="form-select" id="edit-level" name="level">
|
||||
<option value="">Select Level</option>
|
||||
<option value="BUSINESS ANALYST">BUSINESS ANALYST</option>
|
||||
<option value="CONSULTANT">CONSULTANT</option>
|
||||
<option value="SENIOR CONSULTANT">SENIOR CONSULTANT</option>
|
||||
<option value="ASSISTANT MANAGER">ASSISTANT MANAGER</option>
|
||||
<option value="MANAGER">MANAGER</option>
|
||||
<option value="SENIOR MANAGER">SENIOR MANAGER</option>
|
||||
<option value="Intern">Intern</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-legalEntity" class="form-label">Legal Entity</label>
|
||||
<input type="text" class="form-control" id="edit-legalEntity" name="legalEntity">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-functionBusinessUnit" class="form-label">Function Business Unit</label>
|
||||
<input type="text" class="form-control" id="edit-functionBusinessUnit" name="functionBusinessUnit">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-costCenterCode" class="form-label">Cost Center Code</label>
|
||||
<input type="text" class="form-control" id="edit-costCenterCode" name="costCenterCode">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="my-3">Compensation Details</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-newAmendedSalary" class="form-label">Salary (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="edit-newAmendedSalary" name="newAmendedSalary">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-employerContributions" class="form-label">Employer Contributions (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="edit-employerContributions" name="employerContributions">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="my-3">Benefits & Allowances</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-cars" class="form-label">Cars (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="edit-cars" name="cars">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-metlife" class="form-label">Metlife (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="edit-metlife" name="metlife">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-ticketRestaurant" class="form-label">Ticket Restaurant (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="edit-ticketRestaurant" name="ticketRestaurant">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-topusPerMonth" class="form-label">Bonus/Month (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="edit-topusPerMonth" name="topusPerMonth">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="my-3">Revenue Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="edit-grossRevenue" class="form-label">Gross Revenue (€)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="edit-grossRevenue" name="grossRevenue">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Resource Modal -->
|
||||
<div class="modal fade" id="viewResourceModal" tabindex="-1" aria-labelledby="viewResourceModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="viewResourceModalLabel">View Resource</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 class="mb-3">Employee Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">SAP Code</label>
|
||||
<input type="text" class="form-control" id="view-sapCode" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Full Name</label>
|
||||
<input type="text" class="form-control" id="view-fullNameEn" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Level</label>
|
||||
<input type="text" class="form-control" id="view-level" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Legal Entity</label>
|
||||
<input type="text" class="form-control" id="view-legalEntity" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Function Business Unit</label>
|
||||
<input type="text" class="form-control" id="view-functionBusinessUnit" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Cost Center Code</label>
|
||||
<input type="text" class="form-control" id="view-costCenterCode" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="my-3">Compensation Details</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Salary (€)</label>
|
||||
<input type="text" class="form-control" id="view-newAmendedSalary" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Employer Contributions (€)</label>
|
||||
<input type="text" class="form-control" id="view-employerContributions" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Total Salary Cost With Labor (€)</label>
|
||||
<input type="text" class="form-control" id="view-totalSalaryCostWithLabor" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="my-3">Benefits & Allowances</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Cars (€)</label>
|
||||
<input type="text" class="form-control" id="view-cars" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Metlife (€)</label>
|
||||
<input type="text" class="form-control" id="view-metlife" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Ticket Restaurant (€)</label>
|
||||
<input type="text" class="form-control" id="view-ticketRestaurant" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Bonus/Month (€)</label>
|
||||
<input type="text" class="form-control" id="view-topusPerMonth" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="my-3">Cost Analysis</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Daily Cost (€)</label>
|
||||
<input type="text" class="form-control" id="view-dailyCost" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Total Monthly Cost (€)</label>
|
||||
<input type="text" class="form-control" id="view-totalMonthlyCost" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Total Annual Cost (€)</label>
|
||||
<input type="text" class="form-control" id="view-totalAnnualCost" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="my-3">Revenue Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Gross Revenue (€)</label>
|
||||
<input type="text" class="form-control" id="view-grossRevenue" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Import Modal -->
|
||||
<div class="modal fade" id="importModal" tabindex="-1" aria-labelledby="importModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form action="import.php" method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="importModalLabel">Import Excel</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="importFile" class="form-label">Select .csv or .xlsx file</label>
|
||||
<input class="form-control" type="file" id="importFile" name="importFile" accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel" required>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
The file should have columns matching the roster table: sapCode, fullNameEn, legalEntity, etc.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">Upload and Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
3
roster_import_sample.csv
Normal file
3
roster_import_sample.csv
Normal file
@ -0,0 +1,3 @@
|
||||
"first_name","last_name","email","role","start_date","end_date","monthly_cost","currency","billable_rate","billable_currency"
|
||||
"John","Doe","john.doe@example.com","Software Engineer","2023-01-15",,"5000.00","USD","150.00","USD"
|
||||
"Jane","Smith","jane.smith@example.com","Project Manager","2022-11-01","2024-06-30","6000.00","USD","200.00","USD"
|
||||
|
48
save_billing.php
Normal file
48
save_billing.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$response = ['success' => false, 'error' => 'Invalid request'];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$projectId = $_POST['projectId'] ?? null;
|
||||
$billingDataJson = $_POST['billingData'] ?? null;
|
||||
|
||||
if ($projectId && $billingDataJson) {
|
||||
$billingData = json_decode($billingDataJson, true);
|
||||
|
||||
if (is_array($billingData)) {
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("UPDATE billingMonthly SET amount = :amount WHERE projectId = :projectId AND month = :month");
|
||||
|
||||
$pdo->beginTransaction();
|
||||
foreach ($billingData as $item) {
|
||||
$stmt->execute([
|
||||
':amount' => $item['amount'],
|
||||
':projectId' => $projectId,
|
||||
':month' => $item['month']
|
||||
]);
|
||||
}
|
||||
$pdo->commit();
|
||||
|
||||
$response['success'] = true;
|
||||
unset($response['error']);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$response['error'] = 'Database error: ' . $e->getMessage();
|
||||
} catch (Exception $e) {
|
||||
$response['error'] = 'An unexpected error occurred: ' . $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$response['error'] = 'Invalid billing data format.';
|
||||
}
|
||||
} else {
|
||||
$response['error'] = 'Missing required fields.';
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
36
save_expenses.php
Normal file
36
save_expenses.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$response = ['success' => false, 'error' => 'Invalid request'];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$projectId = $_POST['projectId'] ?? null;
|
||||
$month = $_POST['month'] ?? null;
|
||||
$amount = $_POST['amount'] ?? null;
|
||||
|
||||
if ($projectId && $month && $amount !== null) {
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare("UPDATE expensesMonthly SET amount = :amount WHERE projectId = :projectId AND month = :month");
|
||||
$stmt->execute([
|
||||
':amount' => $amount,
|
||||
':projectId' => $projectId,
|
||||
':month' => $month
|
||||
]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
$response['success'] = true;
|
||||
unset($response['error']);
|
||||
} else {
|
||||
$response['error'] = 'No record found to update.';
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
$response['error'] = 'Database error: ' . $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$response['error'] = 'Missing required fields.';
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
140
save_override.php
Normal file
140
save_override.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(0);
|
||||
|
||||
require_once 'db/config.php';
|
||||
|
||||
// Function to send a consistent JSON response, aggressively cleaning any output.
|
||||
function send_json_response($data) {
|
||||
// Ensure no stray output is included.
|
||||
if (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit(); // Terminate immediately
|
||||
}
|
||||
|
||||
// Set a global exception handler to catch any uncaught errors
|
||||
set_exception_handler(function($exception) {
|
||||
error_log($exception->getMessage());
|
||||
send_json_response(['success' => false, 'message' => 'A server error occurred: ' . $exception->getMessage()]);
|
||||
});
|
||||
|
||||
$response_data = [
|
||||
'success' => false,
|
||||
'message' => 'An unknown error occurred.',
|
||||
// Initialize all fields JS expects to avoid 'undefined' issues
|
||||
'wip' => 0,
|
||||
'openingBalance' => 0,
|
||||
'billings' => 0,
|
||||
'expenses' => 0,
|
||||
'cost' => 0,
|
||||
'nsr' => 0,
|
||||
'margin' => 0,
|
||||
];
|
||||
|
||||
ob_clean(); // Clear any pre-existing output
|
||||
try {
|
||||
$data = $_POST;
|
||||
|
||||
$projectId = $data['projectId'] ?? null;
|
||||
$month = $data['month'] ?? null;
|
||||
|
||||
if (!$projectId || !$month) {
|
||||
send_json_response(['success' => false, 'message' => 'Missing project ID or month.']);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$metrics_from_form = [
|
||||
'wip' => $data['wip'] ?? 0,
|
||||
'opening_balance' => $data['openingBalance'] ?? 0,
|
||||
'billing' => $data['billings'] ?? 0,
|
||||
'expenses' => $data['expenses'] ?? 0,
|
||||
'cost' => $data['cost'] ?? 0,
|
||||
];
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Check if records exist, if not, INSERT them. Otherwise, UPDATE.
|
||||
$select_sql = "SELECT COUNT(*) FROM projectFinanceMonthly WHERE projectId = :projectId AND month = :month AND metricName = :metricName";
|
||||
$select_stmt = $pdo->prepare($select_sql);
|
||||
|
||||
$update_sql = "UPDATE projectFinanceMonthly SET value = :value, is_overridden = 1 WHERE projectId = :projectId AND month = :month AND metricName = :metricName";
|
||||
$update_stmt = $pdo->prepare($update_sql);
|
||||
|
||||
$insert_sql = "INSERT INTO projectFinanceMonthly (projectId, month, metricName, value, is_overridden) VALUES (:projectId, :month, :metricName, :value, 1)";
|
||||
$insert_stmt = $pdo->prepare($insert_sql);
|
||||
|
||||
foreach ($metrics_from_form as $metricName => $value) {
|
||||
$select_stmt->execute([
|
||||
'projectId' => $projectId,
|
||||
'month' => $month,
|
||||
'metricName' => $metricName
|
||||
]);
|
||||
$exists = $select_stmt->fetchColumn() > 0;
|
||||
|
||||
if ($exists) {
|
||||
$update_stmt->execute([
|
||||
'value' => $value,
|
||||
'projectId' => $projectId,
|
||||
'month' => $month,
|
||||
'metricName' => $metricName,
|
||||
]);
|
||||
} else {
|
||||
$insert_stmt->execute([
|
||||
'projectId' => $projectId,
|
||||
'month' => $month,
|
||||
'metricName' => $metricName,
|
||||
'value' => $value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
// Recalculate NSR and Margin
|
||||
$billing = floatval($metrics_from_form['billing']);
|
||||
$expenses = floatval($metrics_from_form['expenses']);
|
||||
$cost = floatval($metrics_from_form['cost']);
|
||||
$wip = floatval($metrics_from_form['wip']);
|
||||
$opening_balance = floatval($metrics_from_form['opening_balance']);
|
||||
|
||||
$nsr = $wip + $billing - $opening_balance - $expenses;
|
||||
$margin = ($nsr > 0) ? (($nsr - $cost) / $nsr) : 0;
|
||||
|
||||
|
||||
// Fetch the updated data to send back
|
||||
$stmt = $pdo->prepare("SELECT metricName, value FROM projectFinanceMonthly WHERE projectId = ? AND month = ?");
|
||||
$stmt->execute([$projectId, $month]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$response_data['success'] = true;
|
||||
$response_data['message'] = 'Override saved successfully.';
|
||||
|
||||
foreach ($rows as $row) {
|
||||
// Map db snake_case to form's camelCase
|
||||
if ($row['metricName'] === 'opening_balance') {
|
||||
$response_data['openingBalance'] = $row['value'];
|
||||
} else if ($row['metricName'] === 'billing') {
|
||||
$response_data['billings'] = $row['value'];
|
||||
} else {
|
||||
// Directly map other metrics like 'wip', 'expenses'
|
||||
$response_data[$row['metricName']] = $row['value'];
|
||||
}
|
||||
}
|
||||
|
||||
$response_data['nsr'] = $nsr;
|
||||
$response_data['margin'] = $margin;
|
||||
|
||||
send_json_response($response_data);
|
||||
|
||||
} catch (Exception $e) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
// Log the error and send a JSON response directly
|
||||
error_log($e->getMessage());
|
||||
send_json_response(['success' => false, 'message' => 'A server error occurred: ' . $e->getMessage()]);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user