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 {
|
||||
$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
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),
|
||||
':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) {
|
||||
@ -191,18 +205,19 @@ try {
|
||||
<td><?php echo number_format($row['recoverability'], 2); ?>%</td>
|
||||
<td><?php echo number_format($row['targetMargin'], 2); ?>%</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<button class="btn btn-sm btn-outline-primary me-2 edit-btn"
|
||||
<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>
|
||||
<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; ?>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user