diff --git a/assets/js/forecasting.js b/assets/js/forecasting.js new file mode 100644 index 0000000..5d9c50c --- /dev/null +++ b/assets/js/forecasting.js @@ -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 = ``; + + $.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 + } + }); + } +}); \ No newline at end of file diff --git a/db/migrations/003_create_forecasting_tables.sql b/db/migrations/003_create_forecasting_tables.sql new file mode 100644 index 0000000..fff51e6 --- /dev/null +++ b/db/migrations/003_create_forecasting_tables.sql @@ -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 +); diff --git a/db/migrations/004_add_unique_key_to_forecast_allocation.sql b/db/migrations/004_add_unique_key_to_forecast_allocation.sql new file mode 100644 index 0000000..fc8dc73 --- /dev/null +++ b/db/migrations/004_add_unique_key_to_forecast_allocation.sql @@ -0,0 +1 @@ +ALTER TABLE `forecastAllocation` ADD UNIQUE KEY `unique_allocation` (`forecastingId`, `rosterId`, `month`); diff --git a/db/migrations/005_create_project_finance_monthly_table.sql b/db/migrations/005_create_project_finance_monthly_table.sql new file mode 100644 index 0000000..e0d9ee7 --- /dev/null +++ b/db/migrations/005_create_project_finance_monthly_table.sql @@ -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; diff --git a/export_project_finance.php b/export_project_finance.php new file mode 100644 index 0000000..dc0af68 --- /dev/null +++ b/export_project_finance.php @@ -0,0 +1,106 @@ +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; diff --git a/forecasting.php b/forecasting.php new file mode 100644 index 0000000..0b3c036 --- /dev/null +++ b/forecasting.php @@ -0,0 +1,264 @@ +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']) : []; + +?> + + + + + + Forecasting - <?php echo htmlspecialchars($project['name'] ?? ''); ?> + + + + + + + +
+ Project Financials +
+
+ + +
+ +
+ + + + +
+
+
+
+ + +
+
+
+
+ +
+
+
Resource Allocations
+
+
+
+ + + +
+ + +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + $days): ?> + + + + + + + +
Resource NameLevelActions
No resources allocated yet.
+ + +
+ + + + + +
+
+
+
+
+
+ + + + + + + diff --git a/forecasting_actions.php b/forecasting_actions.php new file mode 100644 index 0000000..109b713 --- /dev/null +++ b/forecasting_actions.php @@ -0,0 +1,186 @@ + $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(); +} diff --git a/index.php b/index.php index 6f6ce6f..b88a5b3 100644 --- a/index.php +++ b/index.php @@ -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"; diff --git a/project_details.php b/project_details.php new file mode 100644 index 0000000..a09fab4 --- /dev/null +++ b/project_details.php @@ -0,0 +1,218 @@ +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']); +} + +?> + + + + + + <?php echo $page_title; ?> + + + + + + +
+ Project Financials +
+
+ + +
+ +
Project with ID not found.
+ + + + +
+
+
Project Information
+
+
+
+
+
+
Name
+
+
WBS
+
+
Start Date
+
+
End Date
+
+
+
+
+
+
Budget
+
+
Recoverability
+
%
+
Target Margin
+
%
+
+
+
+
+
+ + +
+
+
Monthly Financials
+
+ + Export +
+
+
+ + + + + + + + + + + + + + + + + + + +
Metric
+ € +
+
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/projects.php b/projects.php index 2a74934..a46d706 100644 --- a/projects.php +++ b/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 { % % -
- -
- - - -
-
- +
+ + + +
+ +