39428-vm/includes/okr_app.php
Flatlogic Bot 765d998fa1 V1
2026-04-01 10:36:51 +00:00

665 lines
22 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/../db/config.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
function project_name(): string
{
$name = trim((string) ($_SERVER['PROJECT_NAME'] ?? 'OKR Flow'));
return $name !== '' ? $name : 'OKR Flow';
}
function project_description(): string
{
$description = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? 'A lightweight OKR workspace for drafting, submitting, reviewing, and tracking team goals.'));
return $description !== '' ? $description : 'A lightweight OKR workspace for drafting, submitting, reviewing, and tracking team goals.';
}
function okr_profiles(): array
{
return [
'staff' => [
'key' => 'staff',
'name' => 'Amina Staff',
'email' => 'amina.staff@example.com',
'role' => 'Staff',
'level' => 'Staff',
'department' => 'Operations',
],
'approver_team' => [
'key' => 'approver_team',
'name' => 'Noah Team Lead',
'email' => 'noah.team@example.com',
'role' => 'Approver',
'level' => 'Team',
'department' => 'Operations',
],
'approver_manager' => [
'key' => 'approver_manager',
'name' => 'David Manager',
'email' => 'david.manager@example.com',
'role' => 'Approver',
'level' => 'Manager',
'department' => 'Operations',
],
'approver_director' => [
'key' => 'approver_director',
'name' => 'Lina Director',
'email' => 'lina.director@example.com',
'role' => 'Approver',
'level' => 'Director',
'department' => 'Strategy',
],
'approver_ceo' => [
'key' => 'approver_ceo',
'name' => 'Joseph CEO',
'email' => 'joseph.ceo@example.com',
'role' => 'Approver',
'level' => 'CEO',
'department' => 'Executive',
],
'admin' => [
'key' => 'admin',
'name' => 'Rita Admin',
'email' => 'rita.admin@example.com',
'role' => 'Admin',
'level' => 'Admin',
'department' => 'People Ops',
],
];
}
function okr_current_profile(): array
{
$profiles = okr_profiles();
$key = $_SESSION['okr_profile'] ?? 'staff';
if (!isset($profiles[$key])) {
$key = 'staff';
}
return $profiles[$key];
}
function okr_set_profile(string $key): void
{
$profiles = okr_profiles();
if (isset($profiles[$key])) {
$_SESSION['okr_profile'] = $key;
}
}
function okr_csrf_token(): string
{
if (empty($_SESSION['okr_csrf'])) {
$_SESSION['okr_csrf'] = bin2hex(random_bytes(16));
}
return (string) $_SESSION['okr_csrf'];
}
function okr_verify_csrf(): void
{
$posted = $_POST['csrf_token'] ?? '';
if (!hash_equals(okr_csrf_token(), (string) $posted)) {
throw new RuntimeException('Your session expired. Refresh the page and try again.');
}
}
function okr_flash(?string $type = null, ?string $message = null): ?array
{
if ($type !== null && $message !== null) {
$_SESSION['okr_flash'] = ['type' => $type, 'message' => $message];
return null;
}
if (empty($_SESSION['okr_flash']) || !is_array($_SESSION['okr_flash'])) {
return null;
}
$flash = $_SESSION['okr_flash'];
unset($_SESSION['okr_flash']);
return $flash;
}
function okr_level_rank(string $level): int
{
return match ($level) {
'Team' => 1,
'Manager' => 2,
'Director' => 3,
'CEO' => 4,
'Admin' => 5,
default => 0,
};
}
function okr_is_admin(array $profile): bool
{
return ($profile['role'] ?? '') === 'Admin';
}
function okr_is_approver(array $profile): bool
{
return ($profile['role'] ?? '') === 'Approver' || okr_is_admin($profile);
}
function okr_require_schema(): void
{
static $ready = false;
if ($ready) {
return;
}
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS okr_entries (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
objective_title VARCHAR(190) NOT NULL,
owner_name VARCHAR(120) NOT NULL,
owner_email VARCHAR(160) NOT NULL,
owner_role VARCHAR(40) NOT NULL DEFAULT 'Staff',
department_name VARCHAR(120) NOT NULL,
period_label VARCHAR(60) NOT NULL,
approver_name VARCHAR(120) NOT NULL,
approver_level VARCHAR(40) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'Draft',
objective_score DECIMAL(5,2) NOT NULL DEFAULT 0,
key_results_json LONGTEXT NOT NULL,
comments_json LONGTEXT NULL,
activity_json LONGTEXT NULL,
submitted_at DATETIME NULL,
approved_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
INDEX idx_department (department_name),
INDEX idx_owner_email (owner_email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL;
db()->exec($sql);
$ready = true;
}
function okr_clean_text(string $value, int $max = 190): string
{
$value = trim($value);
if ($value === '') {
return '';
}
return function_exists('mb_substr') ? mb_substr($value, 0, $max) : substr($value, 0, $max);
}
function okr_parse_json_field(?string $value): array
{
if ($value === null || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
function okr_safe_score(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
$score = (float) $value;
if ($score < 0) {
$score = 0;
}
if ($score > 100) {
$score = 100;
}
return round($score, 2);
}
function okr_effective_score(array $keyResult, string $status): float
{
if ($status === 'Approved' && isset($keyResult['manager_score']) && $keyResult['manager_score'] !== null && $keyResult['manager_score'] !== '') {
return okr_safe_score($keyResult['manager_score']);
}
return okr_safe_score($keyResult['owner_score'] ?? 0);
}
function okr_calculate_objective_score(array $keyResults, string $status): float
{
if ($keyResults === []) {
return 0.0;
}
$total = 0.0;
$count = 0;
foreach ($keyResults as $keyResult) {
$total += okr_effective_score($keyResult, $status);
$count++;
}
return $count > 0 ? round($total / $count, 1) : 0.0;
}
function okr_activity_item(array $profile, string $message, string $kind = 'update'): array
{
return [
'time' => gmdate('Y-m-d H:i:s'),
'actor_name' => $profile['name'] ?? 'System',
'actor_role' => $profile['role'] ?? 'System',
'actor_level' => $profile['level'] ?? '',
'kind' => $kind,
'message' => $message,
];
}
function okr_comment_item(array $profile, string $message): array
{
return [
'time' => gmdate('Y-m-d H:i:s'),
'actor_name' => $profile['name'] ?? 'System',
'actor_role' => $profile['role'] ?? 'System',
'message' => $message,
];
}
function okr_prepare_entry(array $row): array
{
$row['key_results'] = okr_parse_json_field($row['key_results_json'] ?? '[]');
$row['comments'] = okr_parse_json_field($row['comments_json'] ?? '[]');
$row['activity'] = okr_parse_json_field($row['activity_json'] ?? '[]');
$row['objective_score'] = (float) ($row['objective_score'] ?? 0);
$row['key_result_count'] = count($row['key_results']);
$completed = 0;
foreach ($row['key_results'] as $keyResult) {
if (okr_effective_score($keyResult, (string) ($row['status'] ?? 'Draft')) >= 70) {
$completed++;
}
}
$row['completed_key_results'] = $completed;
return $row;
}
function okr_fetch_entries(?string $status = null): array
{
okr_require_schema();
$sql = 'SELECT * FROM okr_entries';
$params = [];
if ($status !== null && in_array($status, ['Draft', 'Pending', 'Approved'], true)) {
$sql .= ' WHERE status = :status';
$params[':status'] = $status;
}
$sql .= ' ORDER BY updated_at DESC, id DESC';
$stmt = db()->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
return array_map('okr_prepare_entry', $stmt->fetchAll());
}
function okr_fetch_entry(int $id): ?array
{
okr_require_schema();
$stmt = db()->prepare('SELECT * FROM okr_entries WHERE id = :id LIMIT 1');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch();
return $row ? okr_prepare_entry($row) : null;
}
function okr_can_edit_owner(array $entry, array $profile): bool
{
return okr_is_admin($profile) || (($profile['email'] ?? '') === ($entry['owner_email'] ?? ''));
}
function okr_can_review(array $entry, array $profile): bool
{
if (okr_is_admin($profile)) {
return true;
}
if (!okr_is_approver($profile)) {
return false;
}
return okr_level_rank((string) ($profile['level'] ?? '')) >= okr_level_rank((string) ($entry['approver_level'] ?? ''));
}
function okr_normalize_key_results(array $titles, array $dueDates = [], array $ownerScores = [], array $managerScores = []): array
{
$results = [];
foreach ($titles as $index => $title) {
$cleanTitle = okr_clean_text((string) $title, 190);
if ($cleanTitle === '') {
continue;
}
$dueDate = okr_clean_text((string) ($dueDates[$index] ?? ''), 20);
$results[] = [
'title' => $cleanTitle,
'due_date' => $dueDate,
'owner_score' => okr_safe_score($ownerScores[$index] ?? 0),
'manager_score' => ($managerScores[$index] ?? '') === '' ? null : okr_safe_score($managerScores[$index]),
];
}
return $results;
}
function okr_create_entry(array $payload, array $actor): int
{
okr_require_schema();
$keyResults = okr_normalize_key_results(
$payload['key_result_title'] ?? [],
$payload['key_result_due'] ?? []
);
if (count($keyResults) === 0) {
throw new RuntimeException('Add at least one key result before saving the objective.');
}
$objectiveTitle = okr_clean_text((string) ($payload['objective_title'] ?? ''), 190);
$ownerName = okr_clean_text((string) ($payload['owner_name'] ?? ''), 120);
$ownerEmail = filter_var((string) ($payload['owner_email'] ?? ''), FILTER_VALIDATE_EMAIL) ?: '';
$departmentName = okr_clean_text((string) ($payload['department_name'] ?? ''), 120);
$periodLabel = okr_clean_text((string) ($payload['period_label'] ?? ''), 60);
$approverName = okr_clean_text((string) ($payload['approver_name'] ?? ''), 120);
$approverLevel = okr_clean_text((string) ($payload['approver_level'] ?? ''), 40);
if ($objectiveTitle === '' || $ownerName === '' || $ownerEmail === '' || $departmentName === '' || $periodLabel === '' || $approverName === '' || $approverLevel === '') {
throw new RuntimeException('Complete all required fields before creating the objective.');
}
if (!in_array($approverLevel, ['Team', 'Manager', 'Director', 'CEO'], true)) {
throw new RuntimeException('Select a valid approver level.');
}
$activity = [okr_activity_item($actor, 'Draft objective created with ' . count($keyResults) . ' key result(s).', 'created')];
$score = okr_calculate_objective_score($keyResults, 'Draft');
$stmt = db()->prepare(
'INSERT INTO okr_entries (
objective_title, owner_name, owner_email, owner_role, department_name, period_label,
approver_name, approver_level, status, objective_score, key_results_json, comments_json, activity_json
) VALUES (
:objective_title, :owner_name, :owner_email, :owner_role, :department_name, :period_label,
:approver_name, :approver_level, :status, :objective_score, :key_results_json, :comments_json, :activity_json
)'
);
$stmt->bindValue(':objective_title', $objectiveTitle);
$stmt->bindValue(':owner_name', $ownerName);
$stmt->bindValue(':owner_email', $ownerEmail);
$stmt->bindValue(':owner_role', (string) ($actor['role'] ?? 'Staff'));
$stmt->bindValue(':department_name', $departmentName);
$stmt->bindValue(':period_label', $periodLabel);
$stmt->bindValue(':approver_name', $approverName);
$stmt->bindValue(':approver_level', $approverLevel);
$stmt->bindValue(':status', 'Draft');
$stmt->bindValue(':objective_score', $score);
$stmt->bindValue(':key_results_json', json_encode($keyResults, JSON_UNESCAPED_UNICODE));
$stmt->bindValue(':comments_json', json_encode([], JSON_UNESCAPED_UNICODE));
$stmt->bindValue(':activity_json', json_encode($activity, JSON_UNESCAPED_UNICODE));
$stmt->execute();
return (int) db()->lastInsertId();
}
function okr_update_entry_record(array $entry, array $keyResults, array $comments, array $activity, string $status, ?string $submittedAt = null, ?string $approvedAt = null): void
{
$score = okr_calculate_objective_score($keyResults, $status);
$stmt = db()->prepare(
'UPDATE okr_entries
SET status = :status,
objective_score = :objective_score,
key_results_json = :key_results_json,
comments_json = :comments_json,
activity_json = :activity_json,
submitted_at = :submitted_at,
approved_at = :approved_at
WHERE id = :id'
);
$stmt->bindValue(':status', $status);
$stmt->bindValue(':objective_score', $score);
$stmt->bindValue(':key_results_json', json_encode($keyResults, JSON_UNESCAPED_UNICODE));
$stmt->bindValue(':comments_json', json_encode($comments, JSON_UNESCAPED_UNICODE));
$stmt->bindValue(':activity_json', json_encode($activity, JSON_UNESCAPED_UNICODE));
$stmt->bindValue(':submitted_at', $submittedAt);
$stmt->bindValue(':approved_at', $approvedAt);
$stmt->bindValue(':id', (int) $entry['id'], PDO::PARAM_INT);
$stmt->execute();
}
function okr_update_owner_scores(int $id, array $ownerScores, array $actor): void
{
$entry = okr_fetch_entry($id);
if (!$entry) {
throw new RuntimeException('Objective not found.');
}
if (!okr_can_edit_owner($entry, $actor) && !okr_is_admin($actor)) {
throw new RuntimeException('You can only update self-scores for your own objectives.');
}
$keyResults = $entry['key_results'];
foreach ($keyResults as $index => &$keyResult) {
if (array_key_exists($index, $ownerScores)) {
$keyResult['owner_score'] = okr_safe_score($ownerScores[$index]);
}
}
unset($keyResult);
$entry['activity'][] = okr_activity_item($actor, 'Owner scores updated.', 'score');
okr_update_entry_record(
$entry,
$keyResults,
$entry['comments'],
$entry['activity'],
(string) $entry['status'],
$entry['submitted_at'] ?: null,
$entry['approved_at'] ?: null
);
}
function okr_submit_entry(int $id, array $actor): void
{
$entry = okr_fetch_entry($id);
if (!$entry) {
throw new RuntimeException('Objective not found.');
}
if (!okr_can_edit_owner($entry, $actor) && !okr_is_admin($actor)) {
throw new RuntimeException('Only the objective owner or admin can submit this OKR.');
}
$status = 'Pending';
$approvedAt = null;
if (($entry['approver_level'] ?? '') === 'CEO') {
$status = 'Approved';
$approvedAt = gmdate('Y-m-d H:i:s');
foreach ($entry['key_results'] as &$keyResult) {
$keyResult['manager_score'] = okr_safe_score($keyResult['owner_score'] ?? 0);
}
unset($keyResult);
$entry['activity'][] = okr_activity_item($actor, 'Submitted and auto-approved because the approver level is CEO.', 'approved');
} else {
$entry['activity'][] = okr_activity_item($actor, 'Submitted for approval to ' . $entry['approver_name'] . '.', 'submitted');
}
okr_update_entry_record(
$entry,
$entry['key_results'],
$entry['comments'],
$entry['activity'],
$status,
gmdate('Y-m-d H:i:s'),
$approvedAt
);
}
function okr_review_entry(int $id, string $decision, array $managerScores, string $note, array $actor): void
{
$entry = okr_fetch_entry($id);
if (!$entry) {
throw new RuntimeException('Objective not found.');
}
if (!okr_can_review($entry, $actor)) {
throw new RuntimeException('Your current role does not have approval authority for this objective.');
}
$keyResults = $entry['key_results'];
foreach ($keyResults as $index => &$keyResult) {
if (array_key_exists($index, $managerScores)) {
$keyResult['manager_score'] = okr_safe_score($managerScores[$index]);
}
}
unset($keyResult);
$decision = $decision === 'reject' ? 'reject' : 'approve';
$status = $decision === 'approve' ? 'Approved' : 'Draft';
$approvedAt = $decision === 'approve' ? gmdate('Y-m-d H:i:s') : null;
$message = $decision === 'approve' ? 'Approved and scored by ' . ($actor['name'] ?? 'approver') . '.' : 'Returned to draft with feedback from ' . ($actor['name'] ?? 'approver') . '.';
if ($note !== '') {
$message .= ' Note: ' . $note;
$entry['comments'][] = okr_comment_item($actor, $note);
}
$entry['activity'][] = okr_activity_item($actor, $message, $decision === 'approve' ? 'approved' : 'rejected');
okr_update_entry_record(
$entry,
$keyResults,
$entry['comments'],
$entry['activity'],
$status,
$entry['submitted_at'] ?: gmdate('Y-m-d H:i:s'),
$approvedAt
);
}
function okr_add_comment(int $id, string $message, array $actor): void
{
$entry = okr_fetch_entry($id);
if (!$entry) {
throw new RuntimeException('Objective not found.');
}
$message = okr_clean_text($message, 500);
if ($message === '') {
throw new RuntimeException('Write a short comment before posting.');
}
$entry['comments'][] = okr_comment_item($actor, $message);
$entry['activity'][] = okr_activity_item($actor, 'Added a comment.', 'comment');
okr_update_entry_record(
$entry,
$entry['key_results'],
$entry['comments'],
$entry['activity'],
(string) $entry['status'],
$entry['submitted_at'] ?: null,
$entry['approved_at'] ?: null
);
}
function okr_delete_entry(int $id, array $actor): void
{
$entry = okr_fetch_entry($id);
if (!$entry) {
throw new RuntimeException('Objective not found.');
}
if (($entry['status'] ?? '') !== 'Draft') {
throw new RuntimeException('Only draft objectives can be deleted in this first MVP slice.');
}
if (!okr_can_edit_owner($entry, $actor) && !okr_is_admin($actor)) {
throw new RuntimeException('You can only delete your own draft objective.');
}
$stmt = db()->prepare('DELETE FROM okr_entries WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
}
function okr_collect_notifications(array $entries, int $limit = 8): array
{
$notifications = [];
foreach ($entries as $entry) {
foreach (($entry['activity'] ?? []) as $activity) {
$notifications[] = [
'time' => $activity['time'] ?? '',
'message' => $activity['message'] ?? '',
'actor_name' => $activity['actor_name'] ?? 'System',
'kind' => $activity['kind'] ?? 'update',
'objective_title' => $entry['objective_title'] ?? '',
'objective_id' => (int) ($entry['id'] ?? 0),
];
}
}
usort($notifications, static function (array $left, array $right): int {
return strcmp((string) ($right['time'] ?? ''), (string) ($left['time'] ?? ''));
});
return array_slice($notifications, 0, $limit);
}
function okr_dashboard_metrics(array $entries): array
{
$metrics = [
'total' => count($entries),
'draft' => 0,
'pending' => 0,
'approved' => 0,
'average_score' => 0,
'approval_rate' => 0,
'departments' => [],
];
$scoreTotal = 0.0;
foreach ($entries as $entry) {
$status = (string) ($entry['status'] ?? 'Draft');
if ($status === 'Draft') {
$metrics['draft']++;
} elseif ($status === 'Pending') {
$metrics['pending']++;
} elseif ($status === 'Approved') {
$metrics['approved']++;
}
$scoreTotal += (float) ($entry['objective_score'] ?? 0);
$department = (string) ($entry['department_name'] ?? 'Unassigned');
if (!isset($metrics['departments'][$department])) {
$metrics['departments'][$department] = [
'name' => $department,
'count' => 0,
'approved' => 0,
'average_score' => 0,
'score_total' => 0.0,
];
}
$metrics['departments'][$department]['count']++;
if ($status === 'Approved') {
$metrics['departments'][$department]['approved']++;
}
$metrics['departments'][$department]['score_total'] += (float) ($entry['objective_score'] ?? 0);
}
if ($metrics['total'] > 0) {
$metrics['average_score'] = round($scoreTotal / $metrics['total'], 1);
$metrics['approval_rate'] = round(($metrics['approved'] / $metrics['total']) * 100, 1);
}
foreach ($metrics['departments'] as &$department) {
$department['average_score'] = $department['count'] > 0 ? round($department['score_total'] / $department['count'], 1) : 0.0;
unset($department['score_total']);
}
unset($department);
uasort($metrics['departments'], static fn(array $a, array $b): int => $b['count'] <=> $a['count']);
return $metrics;
}
function okr_redirect(string $path): never
{
header('Location: ' . $path);
exit;
}
function okr_time_label(?string $utc): string
{
if (!$utc) {
return '—';
}
$time = strtotime($utc . ' UTC');
if ($time === false) {
return '—';
}
return gmdate('M j, Y · H:i', $time) . ' UTC';
}