version 1 - forecsat initial

This commit is contained in:
Flatlogic Bot 2025-11-25 21:46:02 +00:00
parent a28074f4f8
commit e68686ffc2
10 changed files with 947 additions and 11 deletions

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

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` ADD UNIQUE KEY `unique_allocation` (`forecastingId`, `rosterId`, `month`);

View File

@ -0,0 +1,11 @@
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,
`amount` DECIMAL(15, 2) 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;

106
export_project_finance.php Normal file
View File

@ -0,0 +1,106 @@
<?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']);
foreach ($metrics as $metric) {
foreach ($months as $month) {
$financial_data[$metric][$month] = 0;
}
}
$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) {
$cost_sql = "
SELECT fa.month, SUM(fa.allocatedDays * r.totalMonthlyCost) as totalCost
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' => $latest_forecast['id']]);
$monthly_costs = $cost_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
foreach ($monthly_costs as $month => $cost) {
$formatted_month = date('Y-m-01', strtotime($month));
if (isset($financial_data['Cost'][$formatted_month])) {
$financial_data['Cost'][$formatted_month] = $cost;
}
}
}
}
} 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="index.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();
}

View File

@ -220,7 +220,14 @@ $search_term = $_GET['search'] ?? '';
try {
$pdo = db();
execute_sql_from_file($pdo, __DIR__ . '/db/migrations/001_create_roster_table.sql');
// 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";

218
project_details.php Normal file
View File

@ -0,0 +1,218 @@
<?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;
}
}
// Find the latest forecast version
$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) {
// Calculate monthly costs
$cost_sql = "
SELECT
fa.month,
SUM(fa.allocatedDays * r.totalMonthlyCost) as totalCost
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' => $latest_forecast['id']]);
$monthly_costs = $cost_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
foreach ($monthly_costs as $month => $cost) {
// Ensure month format is consistent
$formatted_month = date('Y-m-01', strtotime($month));
if (isset($financial_data['Cost'][$formatted_month])) {
$financial_data['Cost'][$formatted_month] = $cost;
}
}
}
}
}
// --- 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="index.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="forecasting.php?projectId=<?php echo $project['id']; ?>" class="btn btn-success"><i class="bi bi-bar-chart-line-fill me-2"></i>Forecasting</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 foreach ($months as $month): ?>
<th><?php echo date('M Y', strtotime($month)); ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($metrics as $metric): ?>
<tr>
<td class="fw-bold bg-body-tertiary"><?php echo $metric; ?></td>
<?php foreach ($months as $month): ?>
<td class="text-end">
&euro;<?php echo number_format($financial_data[$metric][$month] ?? 0, 2); ?>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</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>
</body>
</html>

View File

@ -20,6 +20,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
':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) {
@ -99,7 +106,14 @@ function execute_sql_from_file($pdo, $filepath) {
$projects_data = [];
try {
$pdo = db();
execute_sql_from_file($pdo, __DIR__ . '/db/migrations/002_create_projects_table.sql');
// 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) {
@ -192,6 +206,7 @@ try {
<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