Compare commits

...

28 Commits

Author SHA1 Message Date
Flatlogic Bot
ed329e88d7 overrride works perfect 2025-11-28 18:22:14 +00:00
Flatlogic Bot
110ea14346 Auto commit: 2025-11-28T17:57:05.477Z 2025-11-28 17:57:05 +00:00
Flatlogic Bot
dd81fb557e without DiscountRevenue in Roster 2025-11-28 17:29:32 +00:00
Flatlogic Bot
1d88e9ff8c Final-working 2025-11-27 16:31:15 +00:00
Flatlogic Bot
c05b726631 Final - working 2025-11-27 14:37:59 +00:00
Flatlogic Bot
8172a27692 Auto commit: 2025-11-27T13:28:19.787Z 2025-11-27 13:28:19 +00:00
Flatlogic Bot
ffe0142f5f Auto commit: 2025-11-27T12:16:59.506Z 2025-11-27 12:16:59 +00:00
Flatlogic Bot
893b3b9bfa Auto commit: 2025-11-27T11:39:02.361Z 2025-11-27 11:39:02 +00:00
Flatlogic Bot
982b6cfee6 Auto commit: 2025-11-27T11:21:34.461Z 2025-11-27 11:21:34 +00:00
Flatlogic Bot
e23abde359 Auto commit: 2025-11-27T11:20:08.013Z 2025-11-27 11:20:08 +00:00
Flatlogic Bot
09ebb5bdee Auto commit: 2025-11-27T11:18:43.259Z 2025-11-27 11:18:43 +00:00
Flatlogic Bot
3965ab4f7a Auto commit: 2025-11-27T10:58:53.677Z 2025-11-27 10:58:53 +00:00
Flatlogic Bot
7a2b03ad4b over5 2025-11-26 17:17:29 +00:00
Flatlogic Bot
c76337f9a8 over5 2025-11-26 17:09:25 +00:00
Flatlogic Bot
c80560ad92 ove2 2025-11-26 16:32:00 +00:00
Flatlogic Bot
2b9925f4fc overrride - works 2025-11-26 16:27:58 +00:00
Flatlogic Bot
47807955ad Revert to version 4a8e08c 2025-11-26 16:18:35 +00:00
Flatlogic Bot
38ec03060f Revert to version 73cf51a 2025-11-26 16:18:15 +00:00
Flatlogic Bot
89109f468e Revert to version 36ef6e0 2025-11-26 16:15:14 +00:00
Flatlogic Bot
3dfe3836b0 Revert to version 4a8e08c 2025-11-26 14:15:24 +00:00
Flatlogic Bot
36ef6e0316 error - with override 2025-11-26 14:15:17 +00:00
Flatlogic Bot
4a8e08c54b Final v3 - Landing page 2025-11-26 10:28:26 +00:00
Flatlogic Bot
73cf51aa26 Final v2 (Expenses) 2025-11-26 10:23:27 +00:00
Flatlogic Bot
8be23f0e45 Final2 2025-11-26 10:17:48 +00:00
Flatlogic Bot
8cc108c2d6 +WIP+NSR 2025-11-26 09:11:28 +00:00
Flatlogic Bot
00cb8e93bb +billing page 2025-11-26 09:02:25 +00:00
Flatlogic Bot
e68686ffc2 version 1 - forecsat initial 2025-11-25 21:46:02 +00:00
Flatlogic Bot
a28074f4f8 Roster + Projects 2025-11-25 21:05:38 +00:00
37 changed files with 3710 additions and 142 deletions

121
assets/css/custom.css Normal file
View 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
View 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
View 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
View 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
View 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();
});
});
}
});

View 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
View 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
View 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>

View 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)
);

View 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;

View 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
);

View File

@ -0,0 +1 @@
ALTER TABLE `forecastAllocation` DROP INDEX `unique_allocation`, ADD UNIQUE KEY `unique_allocation` (`forecastingId`, `rosterId`, `month`);

View 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;

View 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`)
);

View 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;

View 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`)
);

View File

@ -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;

View 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;

View 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;

View File

@ -0,0 +1 @@
ALTER TABLE `roster` DROP COLUMN `discountedRevenue`;

121
expenses.php Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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());
}

199
index.php
View File

@ -1,150 +1,65 @@
<?php <?php
declare(strict_types=1); require_once __DIR__ . '/db/config.php';
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; try {
$now = date('Y-m-d H:i:s'); $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> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-bs-theme="dark">
<head> <head>
<meta charset="utf-8" /> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Style</title> <title>Project Financial Management</title>
<?php <meta name="description" content="Project Financial Management Tool">
// Read project preview data from environment <meta property="og:title" content="Project Financial Management">
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ''; <meta property="og:description" content="Manage your project financials, roster, and budget.">
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; <meta property="og:image" content="<?php echo htmlspecialchars($_SERVER['PROJECT_IMAGE_URL'] ?? ''); ?>">
?> <meta name="twitter:card" content="summary_large_image">
<?php if ($projectDescription): ?> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Meta description --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' /> <link rel="preconnect" href="https://fonts.googleapis.com">
<!-- Open Graph meta tags --> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Twitter meta tags --> <link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<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; ?>
<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>
</head> </head>
<body> <body>
<main> <div class="top-navbar">
<div class="card"> Project Financial Management
<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>
<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> </div>
</main> <div class="main-wrapper">
<footer> <nav class="sidebar">
Page updated: <?= htmlspecialchars($now) ?> (UTC) <ul class="nav flex-column">
</footer> <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>
</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>

385
project_details.php Normal file
View 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">&euro;<?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 '&euro;' . 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
View 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
View 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>&euro;<?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 (&euro;)</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 (&euro;)</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
View 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 (&euro;)</th>
<th>Employer Contributions (&euro;)</th>
<th>Total Salary Cost (&euro;)</th>
<th>Cars (&euro;)</th>
<th>Metlife (&euro;)</th>
<th>Ticket Restaurant (&euro;)</th>
<th>Bonus/Month (&euro;)</th>
<th>Daily Cost (&euro;)</th>
<th>Total Monthly Cost (&euro;)</th>
<th>Total Annual Cost (&euro;)</th>
<th>Gross Revenue (&euro;)</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>&euro;<?php echo number_format($row['newAmendedSalary'], 2); ?></td>
<td>&euro;<?php echo number_format($row['employerContributions'], 2); ?></td>
<td>&euro;<?php echo number_format($row['totalSalaryCostWithLabor'], 2); ?></td>
<td>&euro;<?php echo number_format($row['cars'], 2); ?></td>
<td>&euro;<?php echo number_format($row['metlife'], 2); ?></td>
<td>&euro;<?php echo number_format($row['ticketRestaurant'], 2); ?></td>
<td>&euro;<?php echo number_format($row['topusPerMonth'], 2); ?></td>
<td>&euro;<?php echo number_format($row['totalMonthlyCost'] / 20, 2); ?></td>
<td>&euro;<?php echo number_format($row['totalMonthlyCost'], 2); ?></td>
<td>&euro;<?php echo number_format($row['totalAnnualCost'], 2); ?></td>
<td>&euro;<?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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</label>
<input type="text" class="form-control" id="view-cars" readonly>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Metlife (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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 (&euro;)</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
View 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"
1 first_name last_name email role start_date end_date monthly_cost currency billable_rate billable_currency
2 John Doe john.doe@example.com Software Engineer 2023-01-15 5000.00 USD 150.00 USD
3 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
View 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
View 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
View 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()]);
}