665 lines
22 KiB
PHP
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';
|
|
}
|