version 1 - forecsat initial
This commit is contained in:
parent
a28074f4f8
commit
e68686ffc2
109
assets/js/forecasting.js
Normal file
109
assets/js/forecasting.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
$(document).ready(function() {
|
||||||
|
// Version selector
|
||||||
|
$('#versionSelector').on('change', function() {
|
||||||
|
const projectId = new URLSearchParams(window.location.search).get('projectId');
|
||||||
|
const version = $(this).val();
|
||||||
|
window.location.href = `forecasting.php?projectId=${projectId}&version=${version}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Roster search with Select2
|
||||||
|
$('#rosterSearch').select2({
|
||||||
|
theme: 'bootstrap-5',
|
||||||
|
placeholder: 'Search by name or SAP code...',
|
||||||
|
allowClear: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Inline editing for allocated days ---
|
||||||
|
const table = document.querySelector('.table');
|
||||||
|
let originalValue = null;
|
||||||
|
|
||||||
|
// Use event delegation on the table body
|
||||||
|
const tableBody = table.querySelector('tbody');
|
||||||
|
|
||||||
|
tableBody.addEventListener('click', function(event) {
|
||||||
|
const cell = event.target.closest('.editable');
|
||||||
|
if (!cell) return; // Clicked outside an editable cell
|
||||||
|
|
||||||
|
// If the cell is already being edited, do nothing
|
||||||
|
if (cell.isContentEditable) return;
|
||||||
|
|
||||||
|
originalValue = cell.textContent.trim();
|
||||||
|
cell.setAttribute('contenteditable', 'true');
|
||||||
|
|
||||||
|
// Select all text in the cell
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(cell);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
cell.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
tableBody.addEventListener('focusout', function(event) {
|
||||||
|
const cell = event.target.closest('.editable');
|
||||||
|
if (!cell) return;
|
||||||
|
|
||||||
|
cell.removeAttribute('contenteditable');
|
||||||
|
const newValue = cell.textContent.trim();
|
||||||
|
|
||||||
|
// Only update if the value has changed and is a valid number
|
||||||
|
if (newValue !== originalValue && !isNaN(newValue)) {
|
||||||
|
updateAllocation(cell, newValue);
|
||||||
|
} else if (newValue !== originalValue) {
|
||||||
|
// Revert if the new value is not a number
|
||||||
|
cell.textContent = originalValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tableBody.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === 'Enter' && event.target.classList.contains('editable')) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.target.blur(); // Triggers focusout to save
|
||||||
|
} else if (event.key === 'Escape' && event.target.classList.contains('editable')) {
|
||||||
|
event.target.textContent = originalValue; // Revert changes
|
||||||
|
event.target.blur(); // Trigger focusout
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateAllocation(cell, allocatedDays) {
|
||||||
|
const rosterId = cell.dataset.rosterId;
|
||||||
|
const month = cell.dataset.month;
|
||||||
|
const forecastingId = cell.dataset.forecastingId;
|
||||||
|
const projectId = new URLSearchParams(window.location.search).get('projectId');
|
||||||
|
const originalContent = cell.innerHTML;
|
||||||
|
|
||||||
|
// Add loading indicator
|
||||||
|
cell.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>`;
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: 'forecasting_actions.php',
|
||||||
|
type: 'POST',
|
||||||
|
dataType: 'json',
|
||||||
|
data: {
|
||||||
|
action: 'update_allocation',
|
||||||
|
forecastingId: forecastingId,
|
||||||
|
rosterId: rosterId,
|
||||||
|
month: month,
|
||||||
|
allocatedDays: allocatedDays,
|
||||||
|
projectId: projectId
|
||||||
|
},
|
||||||
|
success: function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
// The server-side action reloads the page, so no need to update the cell manually.
|
||||||
|
// If the reload were disabled, we'd do: cell.textContent = allocatedDays;
|
||||||
|
window.location.reload(); // Ensure data consistency
|
||||||
|
} else {
|
||||||
|
console.error('Update failed:', response.error);
|
||||||
|
alert('Error: ' + response.error);
|
||||||
|
cell.innerHTML = originalContent; // Revert on failure
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr, status, error) {
|
||||||
|
console.error('AJAX error:', error);
|
||||||
|
alert('An unexpected error occurred. Please try again.');
|
||||||
|
cell.innerHTML = originalContent; // Revert on AJAX error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
19
db/migrations/003_create_forecasting_tables.sql
Normal file
19
db/migrations/003_create_forecasting_tables.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `forecasting` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`projectId` INT NOT NULL,
|
||||||
|
`versionNumber` INT NOT NULL,
|
||||||
|
`createdAt` DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (`projectId`) REFERENCES `projects`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `forecastAllocation` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`forecastingId` INT NOT NULL,
|
||||||
|
`rosterId` INT NOT NULL,
|
||||||
|
`resourceName` VARCHAR(255) NOT NULL,
|
||||||
|
`level` VARCHAR(255) NOT NULL,
|
||||||
|
`month` DATE NOT NULL,
|
||||||
|
`allocatedDays` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
|
||||||
|
FOREIGN KEY (`forecastingId`) REFERENCES `forecasting`(`id`) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (`rosterId`) REFERENCES `roster`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `forecastAllocation` ADD UNIQUE KEY `unique_allocation` (`forecastingId`, `rosterId`, `month`);
|
||||||
11
db/migrations/005_create_project_finance_monthly_table.sql
Normal file
11
db/migrations/005_create_project_finance_monthly_table.sql
Normal 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
106
export_project_finance.php
Normal 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
264
forecasting.php
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/db/config.php';
|
||||||
|
|
||||||
|
$projectId = $_GET['projectId'] ?? null;
|
||||||
|
$versionNumber = $_GET['version'] ?? null;
|
||||||
|
|
||||||
|
if (!$projectId) {
|
||||||
|
die("Project ID is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function execute_sql_from_file($pdo, $filepath) {
|
||||||
|
try {
|
||||||
|
$sql = file_get_contents($filepath);
|
||||||
|
$pdo->exec($sql);
|
||||||
|
return true;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if (strpos($e->getMessage(), 'already exists') === false) {
|
||||||
|
error_log("SQL Execution Error: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = null;
|
||||||
|
$versions = [];
|
||||||
|
$allocations = [];
|
||||||
|
$roster_for_search = [];
|
||||||
|
$db_error = null;
|
||||||
|
|
||||||
|
function get_months($startDate, $endDate) {
|
||||||
|
$start = new DateTime($startDate);
|
||||||
|
$end = new DateTime($endDate);
|
||||||
|
$interval = new DateInterval('P1M');
|
||||||
|
$period = new DatePeriod($start, $interval, $end->modify('+1 month'));
|
||||||
|
$months = [];
|
||||||
|
foreach ($period as $dt) {
|
||||||
|
$months[] = $dt->format('Y-m-01');
|
||||||
|
}
|
||||||
|
return $months;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
// Apply all migrations
|
||||||
|
$migration_files = glob(__DIR__ . '/db/migrations/*.sql');
|
||||||
|
sort($migration_files);
|
||||||
|
foreach ($migration_files as $file) {
|
||||||
|
execute_sql_from_file($pdo, $file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch project details
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM projects WHERE id = :projectId");
|
||||||
|
$stmt->execute([':projectId' => $projectId]);
|
||||||
|
$project = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$project) {
|
||||||
|
die("Project not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch forecasting versions
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM forecasting WHERE projectId = :projectId ORDER BY versionNumber DESC");
|
||||||
|
$stmt->execute([':projectId' => $projectId]);
|
||||||
|
$versions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (empty($versionNumber) && !empty($versions)) {
|
||||||
|
$versionNumber = $versions[0]['versionNumber'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$selected_version = null;
|
||||||
|
if ($versionNumber) {
|
||||||
|
foreach($versions as $v) {
|
||||||
|
if ($v['versionNumber'] == $versionNumber) {
|
||||||
|
$selected_version = $v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch allocations for the selected version
|
||||||
|
if ($selected_version) {
|
||||||
|
$months = get_months($project['startDate'], $project['endDate']);
|
||||||
|
$sql = "SELECT
|
||||||
|
fa.rosterId,
|
||||||
|
fa.resourceName,
|
||||||
|
fa.level,
|
||||||
|
GROUP_CONCAT(fa.month, '|', fa.allocatedDays ORDER BY fa.month) as monthly_allocations
|
||||||
|
FROM forecastAllocation fa
|
||||||
|
WHERE fa.forecastingId = :forecastingId
|
||||||
|
GROUP BY fa.rosterId, fa.resourceName, fa.level
|
||||||
|
ORDER BY fa.resourceName";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([':forecastingId' => $selected_version['id']]);
|
||||||
|
$raw_allocations = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Process allocations into a structured array
|
||||||
|
foreach ($raw_allocations as $raw_row) {
|
||||||
|
$alloc_row = [
|
||||||
|
'rosterId' => $raw_row['rosterId'],
|
||||||
|
'resourceName' => $raw_row['resourceName'],
|
||||||
|
'level' => $raw_row['level'],
|
||||||
|
'months' => []
|
||||||
|
];
|
||||||
|
foreach($months as $month) {
|
||||||
|
$alloc_row['months'][$month] = 0; // Default
|
||||||
|
}
|
||||||
|
$monthly_pairs = explode(',', $raw_row['monthly_allocations']);
|
||||||
|
foreach ($monthly_pairs as $pair) {
|
||||||
|
list($month, $days) = explode('|', $pair);
|
||||||
|
$alloc_row['months'][$month] = $days;
|
||||||
|
}
|
||||||
|
$allocations[] = $alloc_row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch roster for search
|
||||||
|
$stmt = $pdo->query("SELECT id, sapCode, fullNameEn, `level` FROM roster ORDER BY fullNameEn");
|
||||||
|
$roster_for_search = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$db_error = "Database error: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
$months_headers = $project ? get_months($project['startDate'], $project['endDate']) : [];
|
||||||
|
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Forecasting - <?php echo htmlspecialchars($project['name'] ?? ''); ?></title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" />
|
||||||
|
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top-navbar">
|
||||||
|
Project Financials
|
||||||
|
</div>
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<nav class="sidebar">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="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
186
forecasting_actions.php
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/db/config.php';
|
||||||
|
|
||||||
|
$action = $_POST['action'] ?? null;
|
||||||
|
$projectId = $_POST['projectId'] ?? null;
|
||||||
|
|
||||||
|
if (!$action || !$projectId) {
|
||||||
|
redirect_with_error('forecasting.php', $projectId, 'Invalid request.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirect_with_error($page, $projectId, $message) {
|
||||||
|
$version = $_POST['version'] ?? null;
|
||||||
|
$query = http_build_query(array_filter([
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'version' => $version,
|
||||||
|
'error' => $message
|
||||||
|
]));
|
||||||
|
header("Location: {$page}?{$query}");
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirect_with_success($page, $projectId, $message) {
|
||||||
|
$version = $_POST['version'] ?? null;
|
||||||
|
$query = http_build_query(array_filter([
|
||||||
|
'projectId' => $projectId,
|
||||||
|
'version' => $version,
|
||||||
|
'success' => $message
|
||||||
|
]));
|
||||||
|
header("Location: {$page}?{$query}");
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_months($startDate, $endDate) {
|
||||||
|
$start = new DateTime($startDate);
|
||||||
|
$end = new DateTime($endDate);
|
||||||
|
$interval = new DateInterval('P1M');
|
||||||
|
$period = new DatePeriod($start, $interval, $end->modify('+1 month'));
|
||||||
|
$months = [];
|
||||||
|
foreach ($period as $dt) {
|
||||||
|
$months[] = $dt->format('Y-m-01');
|
||||||
|
}
|
||||||
|
return $months;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'new_version':
|
||||||
|
// Find the latest version number
|
||||||
|
$stmt = $pdo->prepare("SELECT MAX(versionNumber) as max_version FROM forecasting WHERE projectId = :projectId");
|
||||||
|
$stmt->execute([':projectId' => $projectId]);
|
||||||
|
$latest_version_num = $stmt->fetchColumn();
|
||||||
|
$new_version_num = $latest_version_num + 1;
|
||||||
|
|
||||||
|
// Get the ID of the latest version to clone from
|
||||||
|
$stmt = $pdo->prepare("SELECT id FROM forecasting WHERE projectId = :projectId AND versionNumber = :versionNumber");
|
||||||
|
$stmt->execute([':projectId' => $projectId, ':versionNumber' => $latest_version_num]);
|
||||||
|
$latest_version_id = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
// Create the new forecasting version
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO forecasting (projectId, versionNumber, createdAt) VALUES (:projectId, :versionNumber, NOW())");
|
||||||
|
$stmt->execute([':projectId' => $projectId, ':versionNumber' => $new_version_num]);
|
||||||
|
$new_version_id = $pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Clone allocations from the latest version if it exists
|
||||||
|
if ($latest_version_id) {
|
||||||
|
$clone_sql = "INSERT INTO forecastAllocation (forecastingId, rosterId, resourceName, level, month, allocatedDays)
|
||||||
|
SELECT :new_version_id, rosterId, resourceName, level, month, allocatedDays
|
||||||
|
FROM forecastAllocation WHERE forecastingId = :latest_version_id";
|
||||||
|
$stmt = $pdo->prepare($clone_sql);
|
||||||
|
$stmt->execute([':new_version_id' => $new_version_id, ':latest_version_id' => $latest_version_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
redirect_with_success('forecasting.php', $projectId, 'New version created successfully.', ['version' => $new_version_num]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'add_resource':
|
||||||
|
$forecastingId = $_POST['forecastingId'] ?? null;
|
||||||
|
$rosterId = $_POST['rosterId'] ?? null;
|
||||||
|
|
||||||
|
if (!$forecastingId || !$rosterId) {
|
||||||
|
redirect_with_error('forecasting.php', $projectId, 'Missing data for adding resource.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch project and roster details
|
||||||
|
$stmt = $pdo->prepare("SELECT startDate, endDate FROM projects WHERE id = :projectId");
|
||||||
|
$stmt->execute([':projectId' => $projectId]);
|
||||||
|
$project = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT fullNameEn, `level` FROM roster WHERE id = :rosterId");
|
||||||
|
$stmt->execute([':rosterId' => $rosterId]);
|
||||||
|
$roster = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$project || !$roster) {
|
||||||
|
redirect_with_error('forecasting.php', $projectId, 'Project or resource not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$months = get_months($project['startDate'], $project['endDate']);
|
||||||
|
$insert_sql = "INSERT INTO forecastAllocation (forecastingId, rosterId, resourceName, level, month, allocatedDays) VALUES (:forecastingId, :rosterId, :resourceName, :level, :month, 0)";
|
||||||
|
$stmt = $pdo->prepare($insert_sql);
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
foreach ($months as $month) {
|
||||||
|
$stmt->execute([
|
||||||
|
':forecastingId' => $forecastingId,
|
||||||
|
':rosterId' => $rosterId,
|
||||||
|
':resourceName' => $roster['fullNameEn'],
|
||||||
|
':level' => $roster['level'],
|
||||||
|
':month' => $month
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
redirect_with_success('forecasting.php', $projectId, 'Resource added to forecast.');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'remove_resource':
|
||||||
|
$forecastingId = $_POST['forecastingId'] ?? null;
|
||||||
|
$rosterId = $_POST['rosterId'] ?? null;
|
||||||
|
|
||||||
|
if (!$forecastingId || !$rosterId) {
|
||||||
|
redirect_with_error('forecasting.php', $projectId, 'Missing data for removing resource.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "DELETE FROM forecastAllocation WHERE forecastingId = :forecastingId AND rosterId = :rosterId";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([':forecastingId' => $forecastingId, ':rosterId' => $rosterId]);
|
||||||
|
|
||||||
|
redirect_with_success('forecasting.php', $projectId, 'Resource removed from forecast.');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'update_allocation':
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$forecastingId = $_POST['forecastingId'] ?? null;
|
||||||
|
$rosterId = $_POST['rosterId'] ?? null;
|
||||||
|
$month = $_POST['month'] ?? null;
|
||||||
|
$allocatedDays = $_POST['allocatedDays'] ?? null;
|
||||||
|
|
||||||
|
if (!$forecastingId || !$rosterId || !$month || !isset($allocatedDays)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid data for update.']);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use INSERT ... ON DUPLICATE KEY UPDATE to handle both new and existing cells
|
||||||
|
$sql = "INSERT INTO forecastAllocation (forecastingId, rosterId, month, allocatedDays, resourceName, level)
|
||||||
|
VALUES (:forecastingId, :rosterId, :month, :allocatedDays,
|
||||||
|
(SELECT fullNameEn FROM roster WHERE id = :rosterId),
|
||||||
|
(SELECT `level` FROM roster WHERE id = :rosterId))
|
||||||
|
ON DUPLICATE KEY UPDATE allocatedDays = :allocatedDays";
|
||||||
|
|
||||||
|
// We need a unique key on (forecastingId, rosterId, month) for this to work.
|
||||||
|
// Let's assume it exists. If not, we need to add it.
|
||||||
|
// ALTER TABLE forecastAllocation ADD UNIQUE KEY `unique_allocation` (`forecastingId`, `rosterId`, `month`);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([
|
||||||
|
':forecastingId' => $forecastingId,
|
||||||
|
':rosterId' => $rosterId,
|
||||||
|
':month' => $month,
|
||||||
|
':allocatedDays' => $allocatedDays
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit();
|
||||||
|
|
||||||
|
default:
|
||||||
|
redirect_with_error('forecasting.php', $projectId, 'Invalid action specified.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
if ($action === 'update_allocation') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Database error: ' . $e->getMessage()]);
|
||||||
|
} else {
|
||||||
|
redirect_with_error('forecasting.php', $projectId, 'Database error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
exit();
|
||||||
|
}
|
||||||
@ -220,7 +220,14 @@ $search_term = $_GET['search'] ?? '';
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = db();
|
$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);
|
seed_roster_data($pdo);
|
||||||
|
|
||||||
$sql = "SELECT * FROM roster";
|
$sql = "SELECT * FROM roster";
|
||||||
|
|||||||
218
project_details.php
Normal file
218
project_details.php
Normal 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">€<?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">
|
||||||
|
€<?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>
|
||||||
35
projects.php
35
projects.php
@ -20,6 +20,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
|
|||||||
':recoverability' => (float)($_POST['recoverability'] ?? 100),
|
':recoverability' => (float)($_POST['recoverability'] ?? 100),
|
||||||
':targetMargin' => (float)($_POST['targetMargin'] ?? 0),
|
':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']);
|
header("Location: " . $_SERVER['PHP_SELF']);
|
||||||
exit();
|
exit();
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
@ -99,7 +106,14 @@ function execute_sql_from_file($pdo, $filepath) {
|
|||||||
$projects_data = [];
|
$projects_data = [];
|
||||||
try {
|
try {
|
||||||
$pdo = db();
|
$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");
|
$stmt = $pdo->query("SELECT * FROM projects ORDER BY name");
|
||||||
$projects_data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$projects_data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
@ -191,18 +205,19 @@ try {
|
|||||||
<td><?php echo number_format($row['recoverability'], 2); ?>%</td>
|
<td><?php echo number_format($row['recoverability'], 2); ?>%</td>
|
||||||
<td><?php echo number_format($row['targetMargin'], 2); ?>%</td>
|
<td><?php echo number_format($row['targetMargin'], 2); ?>%</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<button class="btn btn-sm btn-outline-primary me-2 edit-btn"
|
<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'); ?>'>
|
data-row='<?php echo htmlspecialchars(json_encode($row), ENT_QUOTES, 'UTF-8'); ?>'>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<form action="projects.php" method="POST" onsubmit="return confirm('Are you sure you want to delete this item?');">
|
<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="action" value="delete_project">
|
||||||
<input type="hidden" name="id" value="<?php echo $row['id']; ?>">
|
<input type="hidden" name="id" value="<?php echo $row['id']; ?>">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user