Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
c04a6c2d66 Version 1 2026-04-01 08:42:52 +00:00
9 changed files with 1729 additions and 525 deletions

View File

@ -1,403 +1,467 @@
:root {
--bg: #f1efe3;
--surface: #fcfbf6;
--surface-muted: #f5f3e9;
--border: #d9d5c8;
--border-strong: #bdb7a5;
--text: #111111;
--text-secondary: #5f5c54;
--mantis: #68bb59;
--lime: #32cd32;
--black: #000000;
--danger: #b54545;
--warning: #b98600;
--success: #3f8d3e;
--shadow: 0 8px 24px rgba(17, 17, 17, 0.06);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
}
html {
scroll-behavior: smooth;
}
body {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
color: #212529;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
margin: 0;
min-height: 100vh;
}
.main-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100%;
padding: 20px;
box-sizing: border-box;
position: relative;
z-index: 1;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.chat-container {
width: 100%;
max-width: 600px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 20px;
display: flex;
flex-direction: column;
height: 85vh;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
overflow: hidden;
}
.chat-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.5);
font-weight: 700;
font-size: 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.message {
max-width: 85%;
padding: 0.85rem 1.1rem;
border-radius: 16px;
line-height: 1.5;
font-size: 0.95rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.message.visitor {
align-self: flex-end;
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.message.bot {
align-self: flex-start;
background: #ffffff;
color: #212529;
border-bottom-left-radius: 4px;
}
.chat-input-area {
padding: 1.25rem;
background: rgba(255, 255, 255, 0.5);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.chat-input-area form {
display: flex;
gap: 0.75rem;
}
.chat-input-area input {
flex: 1;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 0.75rem 1rem;
outline: none;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.chat-input-area input:focus {
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
}
.chat-input-area button {
background: #212529;
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.chat-input-area button:hover {
background: #000;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
/* Background Animations */
.bg-animations {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.blob {
position: absolute;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
filter: blur(80px);
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
}
.blob-1 {
top: -10%;
left: -10%;
background: rgba(238, 119, 82, 0.4);
}
.blob-2 {
bottom: -10%;
right: -10%;
background: rgba(35, 166, 213, 0.4);
animation-delay: -7s;
width: 600px;
height: 600px;
}
.blob-3 {
top: 40%;
left: 30%;
background: rgba(231, 60, 126, 0.3);
animation-delay: -14s;
width: 450px;
height: 450px;
}
@keyframes move {
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
}
.header-link {
background: var(--bg);
color: var(--text);
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
color: #fff;
line-height: 1.5;
}
body.auth-shell,
body.detail-shell,
body.app-shell {
min-height: 100vh;
}
a {
color: inherit;
}
.brand-mark {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
font-size: 1rem;
text-decoration: none;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.3s ease;
color: var(--black);
}
.header-link:hover {
background: rgba(0, 0, 0, 0.4);
text-decoration: none;
.brand-mark::before {
content: "";
width: 12px;
height: 12px;
border-radius: 3px;
background: var(--mantis);
box-shadow: 14px 0 0 var(--black), 28px 0 0 var(--lime);
margin-right: 32px;
}
/* Admin Styles */
.admin-container {
max-width: 900px;
margin: 3rem auto;
padding: 2.5rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
border: 1px solid rgba(255, 255, 255, 0.4);
position: relative;
z-index: 1;
}
.admin-container h1 {
margin-top: 0;
color: #212529;
font-weight: 800;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
margin-top: 1.5rem;
}
.table th {
background: transparent;
border: none;
padding: 1rem;
color: #6c757d;
font-weight: 600;
.eyebrow-tag,
.tiny-label {
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
font-size: 0.72rem;
color: var(--text-secondary);
}
.table td {
background: #fff;
padding: 1rem;
border: none;
.display-title {
font-size: clamp(2rem, 5vw, 3.4rem);
line-height: 1.05;
letter-spacing: -0.04em;
max-width: 12ch;
}
.table tr td:first-child { border-radius: 12px 0 0 12px; }
.table tr td:last-child { border-radius: 0 12px 12px 0; }
.form-group {
margin-bottom: 1.25rem;
.surface-card,
.surface-muted,
.metric-card,
.list-row,
.empty-state,
.offcanvas,
.sidebar,
.topbar,
.footer-bar {
background: var(--surface);
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
.surface-card {
border-radius: var(--radius-lg);
padding: var(--space-5);
}
.surface-muted {
background: var(--surface-muted);
border: 1px solid var(--border);
}
.text-secondary,
.form-text,
.small {
color: var(--text-secondary) !important;
}
.auth-panel {
background: var(--surface-muted);
border-right: 1px solid var(--border);
}
.auth-form-column {
background: rgba(252, 251, 246, 0.72);
}
.auth-card {
max-width: 520px;
}
.hero-copy {
max-width: 560px;
}
.btn {
border-radius: 10px;
padding: 0.7rem 1rem;
font-weight: 600;
font-size: 0.9rem;
box-shadow: none !important;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
background: #fff;
transition: all 0.3s ease;
box-sizing: border-box;
.btn-brand {
background: var(--black);
color: #ffffff;
border: 1px solid var(--black);
}
.form-control:focus {
.btn-brand:hover,
.btn-brand:focus {
background: #1a1a1a;
color: #ffffff;
}
.btn-outline-secondary,
.btn-outline-danger,
.btn-notify {
border-color: var(--border-strong);
color: var(--text);
background: var(--surface);
}
.btn-outline-secondary:hover,
.btn-notify:hover,
.btn-notify:focus {
background: var(--surface-muted);
color: var(--text);
border-color: var(--border-strong);
}
.form-control,
.form-select,
textarea {
border-radius: 10px;
border: 1px solid var(--border-strong);
min-height: 44px;
background: #ffffff;
color: var(--text);
}
.form-control:focus,
.form-select:focus,
textarea:focus,
.btn:focus-visible,
.nav-link:focus-visible,
.list-row:focus-visible {
border-color: var(--mantis);
box-shadow: 0 0 0 0.2rem rgba(104, 187, 89, 0.15) !important;
outline: none;
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
}
.header-container {
.app-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 280px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
background: var(--surface-muted);
position: sticky;
top: 0;
height: 100vh;
}
.sidebar-nav .nav-link {
border-radius: 10px;
color: var(--text-secondary);
padding: 0.8rem 0.9rem;
border: 1px solid transparent;
}
.sidebar-nav .nav-link:hover,
.sidebar-nav .nav-link.active {
background: var(--surface);
color: var(--text);
border-color: var(--border);
}
.main-panel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.topbar,
.footer-bar {
padding: 1rem 1.5rem;
background: rgba(252, 251, 246, 0.9);
position: sticky;
z-index: 10;
}
.topbar {
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-links {
display: flex;
gap: 1rem;
}
.admin-card {
background: rgba(255, 255, 255, 0.6);
padding: 2rem;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.5);
margin-bottom: 2.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
.footer-bar {
bottom: 0;
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.82rem;
color: var(--text-secondary);
}
.admin-card h3 {
margin-top: 0;
margin-bottom: 1.5rem;
.topbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.search-wrap {
min-width: min(320px, 100%);
}
.content-area {
padding: 1.5rem;
}
.hero-panel {
display: flex;
justify-content: space-between;
align-items: end;
gap: 1.5rem;
flex-wrap: wrap;
}
.hero-meta-grid,
.stats-grid {
display: grid;
gap: 1rem;
}
.hero-meta-grid {
grid-template-columns: repeat(3, minmax(110px, 1fr));
}
.stats-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.metric-card {
border-radius: var(--radius-lg);
padding: 1.2rem;
}
.metric-value {
font-size: clamp(1.6rem, 4vw, 2rem);
font-weight: 700;
letter-spacing: -0.04em;
margin: 0.35rem 0;
}
.btn-delete {
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.btn-add {
background: #212529;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
}
.btn-save {
background: #0088cc;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
width: 100%;
transition: all 0.3s ease;
}
.webhook-url {
font-size: 0.85em;
color: #555;
margin-top: 0.5rem;
}
.history-table-container {
overflow-x: auto;
background: rgba(255, 255, 255, 0.4);
.list-row {
border-radius: 14px;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
transition: border-color 0.2s ease, transform 0.2s ease;
}
.list-row:hover {
transform: translateY(-1px);
border-color: var(--border-strong);
}
.thin-progress,
.large-progress {
background: #ebe7d8;
border-radius: 999px;
overflow: hidden;
}
.thin-progress {
height: 8px;
}
.large-progress {
height: 12px;
}
.progress-bar.bg-success {
background: var(--mantis) !important;
}
.progress-bar.bg-dark {
background: var(--black) !important;
}
.score-preview {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
min-height: 44px;
padding: 0.75rem 0.9rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.history-table {
width: 100%;
.badge {
border-radius: 999px;
padding: 0.45rem 0.7rem;
font-weight: 600;
font-size: 0.72rem;
text-transform: capitalize;
}
.history-table-time {
width: 15%;
white-space: nowrap;
font-size: 0.85em;
color: #555;
.badge-soft-success {
background: rgba(104, 187, 89, 0.16);
color: #245b24;
}
.history-table-user {
width: 35%;
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px;
.badge-soft-warning {
background: rgba(185, 134, 0, 0.13);
color: #7b5a00;
}
.history-table-ai {
width: 50%;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 8px;
.badge-soft-danger {
background: rgba(181, 69, 69, 0.12);
color: #8f2d2d;
}
.no-messages {
text-align: center;
color: #777;
}
.badge-soft-neutral {
background: rgba(17, 17, 17, 0.08);
color: var(--text-secondary);
}
.notification-badge {
position: absolute;
top: -6px;
right: -8px;
}
.table {
--bs-table-bg: transparent;
--bs-table-border-color: var(--border);
}
.table thead th {
color: var(--text-secondary);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.table tbody tr {
border-color: var(--border);
}
.toast-stack {
position: sticky;
top: 88px;
z-index: 9;
}
.alert {
border-radius: 12px;
}
.offcanvas {
background: var(--surface);
}
@media (max-width: 1199.98px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 30;
transform: translateX(-102%);
transition: transform 0.25s ease;
height: 100vh;
}
.sidebar.is-open {
transform: translateX(0);
}
}
@media (max-width: 767.98px) {
.display-title {
max-width: none;
}
.stats-grid,
.hero-meta-grid {
grid-template-columns: 1fr;
}
.topbar,
.footer-bar,
.content-area,
.sidebar {
padding: 1rem;
}
.topbar {
align-items: flex-start;
}
.topbar-actions {
width: 100%;
}
.search-wrap {
min-width: 100%;
}
.hero-panel,
.list-row,
.footer-bar {
flex-direction: column;
align-items: flex-start;
}
}

View File

@ -1,39 +1,57 @@
document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const chatMessages = document.getElementById('chat-messages');
const searchInput = document.getElementById('tableSearch');
const rows = Array.from(document.querySelectorAll('.js-search-row'));
const appendMessage = (text, sender) => {
const msgDiv = document.createElement('div');
msgDiv.classList.add('message', sender);
msgDiv.textContent = text;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
if (searchInput && rows.length) {
searchInput.addEventListener('input', (event) => {
const query = event.target.value.trim().toLowerCase();
rows.forEach((row) => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(query) ? '' : 'none';
});
});
}
const syncScorePreview = (container) => {
const targetInput = container.querySelector('.js-score-target');
const currentInput = container.querySelector('.js-score-current');
const output = container.querySelector('.js-score-output');
if (!targetInput || !currentInput || !output) {
return;
}
const render = () => {
const target = parseFloat(targetInput.value || '0');
const current = parseFloat(currentInput.value || '0');
let score = 0;
if (target > 0) {
score = Math.max(0, Math.min(100, (current / target) * 100));
}
output.textContent = `${score.toFixed(1).replace('.0', '')}%`;
};
['input', 'change'].forEach((eventName) => {
targetInput.addEventListener(eventName, render);
currentInput.addEventListener(eventName, render);
});
render();
};
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const message = chatInput.value.trim();
if (!message) return;
document.querySelectorAll('#okrCreateForm, #okrReviewForm').forEach(syncScorePreview);
appendMessage(message, 'visitor');
chatInput.value = '';
const sidebar = document.getElementById('sidebarMenu');
const sidebarToggle = document.getElementById('sidebarToggle');
if (sidebar && sidebarToggle) {
sidebarToggle.addEventListener('click', () => {
sidebar.classList.toggle('is-open');
});
}
try {
const response = await fetch('api/chat.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const data = await response.json();
// Artificial delay for realism
setTimeout(() => {
appendMessage(data.reply, 'bot');
}, 500);
} catch (error) {
console.error('Error:', error);
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
}
document.querySelectorAll('[data-auto-dismiss="true"]').forEach((alertEl) => {
window.setTimeout(() => {
alertEl.classList.add('fade');
alertEl.classList.remove('show');
}, 3200);
});
});

5
cookiejar.txt Normal file
View File

@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
127.0.0.1 FALSE / FALSE 0 PHPSESSID jc9efr4es6hc7c92tklakfbc5d

View File

@ -0,0 +1,25 @@
-- Initial MVP slice for the OKR SaaS thin workflow.
CREATE TABLE IF NOT EXISTS okr_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_name VARCHAR(120) NOT NULL,
organization_slug VARCHAR(120) NOT NULL,
owner_name VARCHAR(120) NOT NULL,
owner_email VARCHAR(160) NOT NULL,
owner_role VARCHAR(40) NOT NULL,
department_name VARCHAR(120) NOT NULL,
period_name VARCHAR(120) NOT NULL,
objective_title VARCHAR(255) NOT NULL,
key_result_title VARCHAR(255) NOT NULL,
description TEXT NULL,
target_value DECIMAL(10,2) NOT NULL DEFAULT 100.00,
current_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
score_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
status VARCHAR(40) NOT NULL DEFAULT 'draft',
approval_state VARCHAR(40) NOT NULL DEFAULT 'pending_manager',
manager_comment TEXT NULL,
created_by_email VARCHAR(160) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_scope (organization_slug, department_name, approval_state),
INDEX idx_owner (owner_email, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

752
index.php
View File

@ -1,150 +1,628 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
require_once __DIR__ . '/okr_bootstrap.php';
okr_ensure_schema();
if (empty($_SESSION['okr_user'])) {
require __DIR__ . '/login.php';
exit;
}
$user = okr_current_user();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'create_okr') {
try {
okr_verify_csrf();
$departmentName = trim((string) ($_POST['department_name'] ?? ''));
$periodName = trim((string) ($_POST['period_name'] ?? ''));
$objectiveTitle = trim((string) ($_POST['objective_title'] ?? ''));
$keyResultTitle = trim((string) ($_POST['key_result_title'] ?? ''));
$description = trim((string) ($_POST['description'] ?? ''));
$targetValue = (float) ($_POST['target_value'] ?? 0);
$currentValue = (float) ($_POST['current_value'] ?? 0);
$errors = [];
if ($departmentName === '') {
$errors[] = 'Department is required.';
}
if ($periodName === '') {
$errors[] = 'OKR period is required.';
}
if ($objectiveTitle === '' || strlen($objectiveTitle) < 8) {
$errors[] = 'Objective title must be at least 8 characters.';
}
if ($keyResultTitle === '' || strlen($keyResultTitle) < 8) {
$errors[] = 'Key result title must be at least 8 characters.';
}
if ($targetValue <= 0) {
$errors[] = 'Target value must be greater than 0.';
}
if ($currentValue < 0) {
$errors[] = 'Current value cannot be negative.';
}
if ($errors !== []) {
throw new RuntimeException(implode(' ', $errors));
}
$scorePercent = okr_calculate_score($currentValue, $targetValue);
$approvalState = okr_is_approver($user['role']) ? 'approved' : 'pending_manager';
$status = $approvalState === 'approved' ? ($scorePercent >= 100 ? 'completed' : 'active') : 'submitted';
$managerComment = $approvalState === 'approved' ? 'Auto-approved on submission by leadership role.' : null;
$stmt = db()->prepare(
'INSERT INTO okr_items (
organization_name,
organization_slug,
owner_name,
owner_email,
owner_role,
department_name,
period_name,
objective_title,
key_result_title,
description,
target_value,
current_value,
score_percent,
status,
approval_state,
manager_comment,
created_by_email
) VALUES (
:organization_name,
:organization_slug,
:owner_name,
:owner_email,
:owner_role,
:department_name,
:period_name,
:objective_title,
:key_result_title,
:description,
:target_value,
:current_value,
:score_percent,
:status,
:approval_state,
:manager_comment,
:created_by_email
)'
);
$stmt->execute([
':organization_name' => $user['organization_name'],
':organization_slug' => $user['organization_slug'],
':owner_name' => $user['name'],
':owner_email' => $user['email'],
':owner_role' => $user['role'],
':department_name' => $departmentName,
':period_name' => $periodName,
':objective_title' => $objectiveTitle,
':key_result_title' => $keyResultTitle,
':description' => $description !== '' ? $description : null,
':target_value' => $targetValue,
':current_value' => $currentValue,
':score_percent' => $scorePercent,
':status' => $status,
':approval_state' => $approvalState,
':manager_comment' => $managerComment,
':created_by_email' => $user['email'],
]);
okr_flash('success', 'Objective created and routed into the workflow.');
header('Location: index.php#my-okrs');
exit;
} catch (Throwable $exception) {
okr_flash('danger', $exception->getMessage());
header('Location: index.php#my-okrs');
exit;
}
}
$projectName = okr_app_name();
$projectDescription = okr_meta_description();
$projectImageUrl = env_value('PROJECT_IMAGE_URL');
$flash = okr_pull_flash();
$csrfToken = okr_csrf_token();
$scopeParams = okr_scope_params($user);
$scopeClause = okr_scope_clause();
$summaryStmt = db()->prepare(
'SELECT
COUNT(*) AS total_items,
SUM(approval_state = "pending_manager") AS pending_items,
SUM(approval_state = "approved") AS approved_items,
ROUND(COALESCE(AVG(score_percent), 0), 1) AS average_score,
SUM(status = "completed") AS completed_items
FROM okr_items
WHERE ' . $scopeClause
);
foreach ($scopeParams as $key => $value) {
$summaryStmt->bindValue($key, $value);
}
$summaryStmt->execute();
$summary = $summaryStmt->fetch() ?: ['total_items' => 0, 'pending_items' => 0, 'approved_items' => 0, 'average_score' => 0, 'completed_items' => 0];
$recentStmt = db()->prepare(
'SELECT id, organization_name, owner_name, owner_role, department_name, objective_title, score_percent, approval_state, updated_at
FROM okr_items
WHERE ' . $scopeClause . '
ORDER BY updated_at DESC
LIMIT 6'
);
foreach ($scopeParams as $key => $value) {
$recentStmt->bindValue($key, $value);
}
$recentStmt->execute();
$recentItems = $recentStmt->fetchAll();
$listStmt = db()->prepare(
'SELECT id, owner_name, owner_role, department_name, period_name, objective_title, key_result_title, score_percent, status, approval_state, updated_at
FROM okr_items
WHERE ' . $scopeClause . '
ORDER BY created_at DESC
LIMIT 24'
);
foreach ($scopeParams as $key => $value) {
$listStmt->bindValue($key, $value);
}
$listStmt->execute();
$okrItems = $listStmt->fetchAll();
$myStmt = db()->prepare(
'SELECT id, objective_title, key_result_title, score_percent, approval_state, updated_at
FROM okr_items
WHERE ' . $scopeClause . ' AND owner_email = :owner_email
ORDER BY created_at DESC
LIMIT 6'
);
foreach ($scopeParams as $key => $value) {
$myStmt->bindValue($key, $value);
}
$myStmt->bindValue(':owner_email', $user['email']);
$myStmt->execute();
$myItems = $myStmt->fetchAll();
$approvalInbox = [];
if (okr_is_approver($user['role'])) {
$approvalStmt = db()->prepare(
'SELECT id, owner_name, department_name, objective_title, key_result_title, score_percent, updated_at
FROM okr_items
WHERE ' . $scopeClause . ' AND approval_state = :approval_state
ORDER BY updated_at DESC
LIMIT 5'
);
foreach ($scopeParams as $key => $value) {
$approvalStmt->bindValue($key, $value);
}
$approvalStmt->bindValue(':approval_state', 'pending_manager');
$approvalStmt->execute();
$approvalInbox = $approvalStmt->fetchAll();
}
$departmentStmt = db()->prepare(
'SELECT department_name, COUNT(*) AS item_count, ROUND(COALESCE(AVG(score_percent), 0), 1) AS department_score
FROM okr_items
WHERE ' . $scopeClause . '
GROUP BY department_name
ORDER BY item_count DESC, department_name ASC
LIMIT 4'
);
foreach ($scopeParams as $key => $value) {
$departmentStmt->bindValue($key, $value);
}
$departmentStmt->execute();
$departmentRows = $departmentStmt->fetchAll();
$pendingCount = okr_notification_count($user);
$completionRate = ((int) ($summary['total_items'] ?? 0)) > 0 ? round(((int) ($summary['completed_items'] ?? 0) / (int) $summary['total_items']) * 100) : 0;
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($projectName) ?> · Workspace</title>
<meta name="description" content="<?= e($projectDescription) ?>">
<?php if ($projectDescription !== ''): ?>
<meta property="og:description" content="<?= e($projectDescription) ?>">
<meta property="twitter:description" content="<?= e($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php if ($projectImageUrl !== ''): ?>
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= time() ?>">
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
<body class="app-shell">
<div class="app-layout">
<aside class="sidebar border-end" id="sidebarMenu">
<div class="sidebar-top">
<a href="index.php" class="brand-mark">Aligned OKR</a>
<div class="small text-secondary mt-2">Tenant: <?= e($user['organization_name']) ?></div>
</div>
<nav class="nav flex-column gap-1 sidebar-nav">
<a class="nav-link active" href="#dashboard">Dashboard</a>
<a class="nav-link" href="#corporate-okrs">Corporate OKRs</a>
<a class="nav-link" href="#department-okrs">Department OKRs</a>
<a class="nav-link" href="#staff-okrs">Staff OKRs</a>
<a class="nav-link" href="#my-okrs">My OKRs</a>
</nav>
<div class="surface-card p-3 mt-auto">
<div class="small text-secondary mb-2">Current access</div>
<div class="fw-semibold"><?= e($user['role']) ?></div>
<div class="small text-secondary"><?= e($user['email']) ?></div>
<div class="small text-secondary">Org key: <?= e($user['organization_slug']) ?></div>
</div>
</aside>
<div class="main-panel">
<header class="topbar border-bottom">
<div class="d-flex align-items-center gap-2">
<button class="btn btn-outline-secondary btn-sm d-lg-none" type="button" id="sidebarToggle">Menu</button>
<div>
<div class="small text-secondary">Operational strategy workspace</div>
<h1 class="h4 mb-0">Dashboard</h1>
</div>
</div>
<div class="topbar-actions">
<div class="search-wrap">
<input type="search" id="tableSearch" class="form-control" placeholder="Search OKRs, people, departments">
</div>
<button class="btn btn-notify position-relative" type="button" data-bs-toggle="offcanvas" data-bs-target="#notificationsDrawer" aria-controls="notificationsDrawer">
Notifications
<?php if ($pendingCount > 0): ?>
<span class="badge rounded-pill text-bg-dark notification-badge"><?= e((string) $pendingCount) ?></span>
<?php endif; ?>
</button>
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<?= e($user['name']) ?>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li><span class="dropdown-item-text small text-secondary"><?= e($user['organization_name']) ?></span></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="logout.php" class="px-2">
<button class="btn btn-sm btn-outline-danger w-100" type="submit">Log out</button>
</form>
</li>
</ul>
</div>
</div>
</header>
<main class="content-area">
<section class="hero-panel surface-card" id="dashboard">
<div>
<p class="tiny-label mb-2">First MVP delivery</p>
<h2 class="h3 mb-2">A working OKR workflow for one tenant-aware organization at a time.</h2>
<p class="text-secondary mb-0">Create a personal objective, view organization-wide progress, and move items through manager review with auto-scored key results.</p>
</div>
<div class="hero-meta-grid">
<div>
<div class="small text-secondary">Workspace</div>
<div class="fw-semibold"><?= e($user['organization_name']) ?></div>
</div>
<div>
<div class="small text-secondary">Role</div>
<div class="fw-semibold"><?= e($user['role']) ?></div>
</div>
<div>
<div class="small text-secondary">Version</div>
<div class="fw-semibold">0.1 MVP</div>
</div>
</div>
</section>
<?php if ($flash): ?>
<div class="toast-stack">
<div class="alert alert-<?= e($flash['type']) ?> border-0 shadow-sm" role="alert" data-auto-dismiss="true">
<?= e($flash['message']) ?>
</div>
</div>
<?php endif; ?>
<section class="stats-grid mt-4">
<article class="metric-card surface-card">
<div class="small text-secondary">Total OKRs</div>
<div class="metric-value"><?= e((string) ($summary['total_items'] ?? 0)) ?></div>
<div class="small text-secondary">Scoped to <?= okr_is_super_admin() ? 'all organizations' : 'your organization' ?></div>
</article>
<article class="metric-card surface-card">
<div class="small text-secondary">Pending approvals</div>
<div class="metric-value"><?= e((string) ($summary['pending_items'] ?? 0)) ?></div>
<div class="small text-secondary">Queue for line-manager review</div>
</article>
<article class="metric-card surface-card">
<div class="small text-secondary">Approved items</div>
<div class="metric-value"><?= e((string) ($summary['approved_items'] ?? 0)) ?></div>
<div class="small text-secondary">Includes leadership auto-approvals</div>
</article>
<article class="metric-card surface-card">
<div class="small text-secondary">Average score</div>
<div class="metric-value"><?= e((string) ($summary['average_score'] ?? 0)) ?>%</div>
<div class="small text-secondary">Calculated from key result progress</div>
</article>
</section>
<section class="row g-4 mt-1">
<div class="col-xl-8">
<div class="surface-card h-100" id="corporate-okrs">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<p class="tiny-label mb-2">Corporate OKRs</p>
<h2 class="h5 mb-1">Recent strategic objectives</h2>
<p class="small text-secondary mb-0">A compact hierarchical view of the latest objectives and key results in scope.</p>
</div>
<span class="small text-secondary">Use the search field in the header to filter all table rows.</span>
</div>
<?php if ($recentItems === []): ?>
<div class="empty-state border rounded-3 p-4 text-center">
<div class="fw-semibold mb-2">No OKRs yet</div>
<p class="small text-secondary mb-0">Create your first objective in the My OKRs section to populate the dashboard.</p>
</div>
<?php else: ?>
<div class="vstack gap-3">
<?php foreach ($recentItems as $item): ?>
<a class="list-row text-decoration-none" href="okr_detail.php?id=<?= e((string) $item['id']) ?>">
<div>
<div class="fw-semibold text-dark"><?= e($item['objective_title']) ?></div>
<div class="small text-secondary"><?= e($item['owner_name']) ?> · <?= e($item['owner_role']) ?> · <?= e($item['department_name']) ?></div>
</div>
<div class="text-end">
<span class="badge <?= e(okr_badge_class((string) $item['approval_state'])) ?> mb-2"><?= e($item['approval_state']) ?></span>
<div class="small fw-semibold"><?= e((string) $item['score_percent']) ?>%</div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<div class="col-xl-4">
<div class="surface-card h-100" id="department-okrs">
<p class="tiny-label mb-2">Department OKRs</p>
<h2 class="h5 mb-3">Distribution by department</h2>
<?php if ($departmentRows === []): ?>
<p class="small text-secondary mb-0">Department insights appear after your team creates records.</p>
<?php else: ?>
<div class="vstack gap-3">
<?php foreach ($departmentRows as $departmentRow): ?>
<div>
<div class="d-flex justify-content-between small mb-1">
<span class="fw-semibold text-dark"><?= e($departmentRow['department_name']) ?></span>
<span class="text-secondary"><?= e((string) $departmentRow['item_count']) ?> OKRs</span>
</div>
<div class="progress thin-progress" role="progressbar" aria-valuenow="<?= e((string) $departmentRow['department_score']) ?>" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar bg-success" style="width: <?= e((string) $departmentRow['department_score']) ?>%"></div>
</div>
<div class="small text-secondary mt-1">Average score <?= e((string) $departmentRow['department_score']) ?>%</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<hr>
<p class="tiny-label mb-2" id="staff-okrs">Staff OKRs</p>
<h3 class="h6 mb-2">Workflow completion</h3>
<div class="progress large-progress mb-2" role="progressbar" aria-valuenow="<?= e((string) $completionRate) ?>" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar bg-dark" style="width: <?= e((string) $completionRate) ?>%"></div>
</div>
<div class="small text-secondary"><?= e((string) $completionRate) ?>% of in-scope objectives are completed.</div>
</div>
</div>
</section>
<section class="row g-4 mt-1">
<div class="col-xl-7">
<div class="surface-card h-100" id="my-okrs">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4 flex-wrap">
<div>
<p class="tiny-label mb-2">My OKRs</p>
<h2 class="h5 mb-1">Create a new objective</h2>
<p class="small text-secondary mb-0">This thin slice covers create confirmation list detail approval.</p>
</div>
<div class="small text-secondary">All writes use PDO prepared statements.</div>
</div>
<form method="post" class="row g-3" id="okrCreateForm">
<input type="hidden" name="action" value="create_okr">
<input type="hidden" name="csrf_token" value="<?= e($csrfToken) ?>">
<div class="col-md-6">
<label class="form-label" for="department_name">Department</label>
<input class="form-control" id="department_name" name="department_name" type="text" placeholder="Revenue Operations" required>
</div>
<div class="col-md-6">
<label class="form-label" for="period_name">OKR period</label>
<input class="form-control" id="period_name" name="period_name" type="text" placeholder="Q2 2026" required>
</div>
<div class="col-12">
<label class="form-label" for="objective_title">Objective</label>
<input class="form-control" id="objective_title" name="objective_title" type="text" placeholder="Improve enterprise expansion revenue quality" required>
</div>
<div class="col-12">
<label class="form-label" for="key_result_title">Key result</label>
<input class="form-control" id="key_result_title" name="key_result_title" type="text" placeholder="Increase qualified pipeline conversion from 21% to 33%" required>
</div>
<div class="col-12">
<label class="form-label" for="description">Notes</label>
<textarea class="form-control" id="description" name="description" rows="4" placeholder="Add success criteria, dependencies, and any approval notes."></textarea>
</div>
<div class="col-md-4">
<label class="form-label" for="target_value">Target value</label>
<input class="form-control js-score-target" id="target_value" name="target_value" type="number" min="1" step="0.1" value="100" required>
</div>
<div class="col-md-4">
<label class="form-label" for="current_value">Current value</label>
<input class="form-control js-score-current" id="current_value" name="current_value" type="number" min="0" step="0.1" value="0" required>
</div>
<div class="col-md-4">
<label class="form-label">Projected score</label>
<div class="score-preview surface-muted">
<strong class="js-score-output">0%</strong>
<span class="small text-secondary">Calculated automatically</span>
</div>
</div>
<div class="col-12 d-flex gap-2 flex-wrap">
<button class="btn btn-brand" type="submit">Create objective</button>
<a class="btn btn-outline-secondary" href="#okr-table">View current records</a>
</div>
</form>
</div>
</div>
<div class="col-xl-5">
<div class="surface-card h-100">
<p class="tiny-label mb-2">Approval inbox</p>
<h2 class="h5 mb-3">Items waiting for review</h2>
<?php if (!okr_is_approver($user['role'])): ?>
<div class="alert alert-light border small mb-0">Only Manager, Director, CEO, Admin, and Super Admin roles can approve or reject submitted OKRs in this first release.</div>
<?php elseif ($approvalInbox === []): ?>
<div class="empty-state border rounded-3 p-4 text-center">
<div class="fw-semibold mb-2">Inbox is clear</div>
<p class="small text-secondary mb-0">Pending approvals will appear here as staff submit new OKRs.</p>
</div>
<?php else: ?>
<div class="vstack gap-3">
<?php foreach ($approvalInbox as $pending): ?>
<div class="surface-muted p-3 rounded-3">
<div class="d-flex justify-content-between gap-3">
<div>
<div class="fw-semibold"><?= e($pending['objective_title']) ?></div>
<div class="small text-secondary"><?= e($pending['owner_name']) ?> · <?= e($pending['department_name']) ?></div>
</div>
<div class="text-end small">
<div class="fw-semibold"><?= e((string) $pending['score_percent']) ?>%</div>
<div class="text-secondary">Current score</div>
</div>
</div>
<div class="mt-3">
<a class="btn btn-sm btn-outline-dark" href="okr_detail.php?id=<?= e((string) $pending['id']) ?>">Review item</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</section>
<section class="surface-card mt-4" id="okr-table">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4 flex-wrap">
<div>
<p class="tiny-label mb-2">Shared list</p>
<h2 class="h5 mb-1">In-scope OKR records</h2>
<p class="small text-secondary mb-0">Each record opens a detail page for approvals, comments, and score updates.</p>
</div>
<div class="small text-secondary">Showing up to 24 most recent records.</div>
</div>
<?php if ($okrItems === []): ?>
<div class="empty-state border rounded-3 p-5 text-center">
<div class="fw-semibold mb-2">Your workspace is ready for the first OKR</div>
<p class="small text-secondary mb-0">Create one above to activate the dashboard, approval inbox, and analytics cards.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table align-middle mb-0" id="okrTable">
<thead>
<tr>
<th>Owner</th>
<th>Department</th>
<th>Objective</th>
<th>Score</th>
<th>Status</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($okrItems as $item): ?>
<tr class="js-search-row">
<td>
<div class="fw-semibold"><?= e($item['owner_name']) ?></div>
<div class="small text-secondary"><?= e($item['owner_role']) ?></div>
</td>
<td>
<div><?= e($item['department_name']) ?></div>
<div class="small text-secondary"><?= e($item['period_name']) ?></div>
</td>
<td>
<div class="fw-semibold"><?= e($item['objective_title']) ?></div>
<div class="small text-secondary"><?= e($item['key_result_title']) ?></div>
</td>
<td>
<div class="fw-semibold"><?= e((string) $item['score_percent']) ?>%</div>
<div class="small text-secondary">Updated <?= e(date('M j', strtotime((string) $item['updated_at']))) ?></div>
</td>
<td>
<span class="badge <?= e(okr_badge_class((string) $item['approval_state'])) ?> mb-2"><?= e($item['approval_state']) ?></span>
<div class="small text-secondary text-capitalize"><?= e($item['status']) ?></div>
</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-dark" href="okr_detail.php?id=<?= e((string) $item['id']) ?>">Open</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<section class="surface-card mt-4">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<p class="tiny-label mb-2">Personal queue</p>
<h2 class="h5 mb-1">Your latest submissions</h2>
<p class="small text-secondary mb-0">Quick access to your own items inside the current organization scope.</p>
</div>
<a class="btn btn-outline-secondary btn-sm" href="#my-okrs">Create another</a>
</div>
<div class="row g-3 mt-1">
<?php if ($myItems === []): ?>
<div class="col-12">
<div class="empty-state border rounded-3 p-4 text-center small text-secondary">No personal OKRs created yet in this workspace.</div>
</div>
<?php else: ?>
<?php foreach ($myItems as $item): ?>
<div class="col-md-6 col-xl-4">
<a class="surface-muted p-3 rounded-3 h-100 d-block text-decoration-none text-dark" href="okr_detail.php?id=<?= e((string) $item['id']) ?>">
<div class="fw-semibold mb-2"><?= e($item['objective_title']) ?></div>
<div class="small text-secondary mb-3"><?= e($item['key_result_title']) ?></div>
<div class="d-flex justify-content-between align-items-center">
<span class="badge <?= e(okr_badge_class((string) $item['approval_state'])) ?>"><?= e($item['approval_state']) ?></span>
<span class="fw-semibold"><?= e((string) $item['score_percent']) ?>%</span>
</div>
</a>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
</main>
<footer class="footer-bar border-top">
<div>Aligned OKR Cloud · Version 0.1 MVP</div>
<div>© <?= e(date('Y')) ?> <?= e($user['organization_name']) ?> workspace</div>
</footer>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
</div>
<div class="offcanvas offcanvas-end" tabindex="-1" id="notificationsDrawer" aria-labelledby="notificationsDrawerLabel">
<div class="offcanvas-header">
<h2 class="offcanvas-title h5" id="notificationsDrawerLabel">Notifications</h2>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<div class="surface-muted rounded-3 p-3 mb-3">
<div class="fw-semibold mb-1">Approval workload</div>
<div class="small text-secondary"><?= e((string) $pendingCount) ?> item(s) are currently waiting for a line manager or leadership decision.</div>
</div>
<div class="small text-secondary">This initial delivery uses lightweight refreshes and contextual alerts. Real-time comment streams and richer notifications can be layered onto the same workflow next.</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="assets/js/main.js?v=<?= time() ?>" defer></script>
</body>
</html>

176
login.php Normal file
View File

@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/okr_bootstrap.php';
okr_ensure_schema();
if (!empty($_SESSION['okr_user'])) {
header('Location: index.php');
exit;
}
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
okr_verify_csrf();
$organizationName = trim((string) ($_POST['organization_name'] ?? ''));
$organizationSlug = strtolower(trim((string) ($_POST['organization_slug'] ?? '')));
$fullName = trim((string) ($_POST['full_name'] ?? ''));
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
$role = trim((string) ($_POST['role'] ?? 'Staff'));
if ($organizationName === '' || strlen($organizationName) < 2) {
$errors[] = 'Enter a valid organization name.';
}
if ($organizationSlug === '' || !preg_match('/^[a-z0-9-]{3,40}$/', $organizationSlug)) {
$errors[] = 'Use a lowercase org key with letters, numbers, or dashes.';
}
if ($fullName === '' || strlen($fullName) < 2) {
$errors[] = 'Enter your full name.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Enter a valid work email address.';
}
if (!in_array($role, okr_roles(), true)) {
$errors[] = 'Choose a valid role.';
}
if ($errors === []) {
$_SESSION['okr_user'] = [
'organization_name' => $organizationName,
'organization_slug' => $organizationSlug,
'name' => $fullName,
'email' => $email,
'role' => $role,
];
okr_flash('success', 'Welcome back. Your workspace is ready.');
header('Location: index.php');
exit;
}
} catch (Throwable $exception) {
$errors[] = $exception->getMessage();
}
}
$projectName = okr_app_name();
$projectDescription = okr_meta_description();
$projectImageUrl = env_value('PROJECT_IMAGE_URL');
$csrfToken = okr_csrf_token();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($projectName) ?> · Secure sign in</title>
<meta name="description" content="<?= e($projectDescription) ?>">
<?php if ($projectDescription !== ''): ?>
<meta property="og:description" content="<?= e($projectDescription) ?>">
<meta property="twitter:description" content="<?= e($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl !== ''): ?>
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
<?php endif; ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= time() ?>">
</head>
<body class="auth-shell">
<div class="container-fluid px-0">
<div class="row g-0 min-vh-100">
<div class="col-lg-6 auth-panel d-flex flex-column justify-content-between p-4 p-lg-5">
<div>
<div class="d-flex align-items-center justify-content-between mb-5">
<a class="brand-mark" href="login.php">Aligned OKR</a>
<span class="eyebrow-tag">Multi-tenant SaaS</span>
</div>
<div class="hero-copy">
<p class="text-uppercase tiny-label mb-3">Strategic execution platform</p>
<h1 class="display-title mb-3">Run one tenant-aware OKR workflow from sign-in to approval.</h1>
<p class="text-secondary mb-4">This initial release gives each organization an isolated workspace with a clean dashboard, scoped OKR records, manager approvals, and lightweight analytics.</p>
</div>
<div class="row g-3 mt-2">
<div class="col-sm-6">
<div class="surface-card h-100 p-3">
<div class="small text-secondary mb-2">Included now</div>
<div class="fw-semibold mb-1">My OKRs workflow</div>
<p class="small text-secondary mb-0">Create objectives, track key results, and route them into approval with automatic score calculation.</p>
</div>
</div>
<div class="col-sm-6">
<div class="surface-card h-100 p-3">
<div class="small text-secondary mb-2">Ready for managers</div>
<div class="fw-semibold mb-1">Approval inbox</div>
<p class="small text-secondary mb-0">Managers, directors, CEOs, and super admins can review progress, leave feedback, and approve or reject.</p>
</div>
</div>
</div>
</div>
<div class="small text-secondary mt-4">Version 0.1 · Designed for secure internal planning and execution.</div>
</div>
<div class="col-lg-6 d-flex align-items-center justify-content-center p-4 p-lg-5 auth-form-column">
<div class="surface-card auth-card w-100">
<div class="mb-4">
<p class="text-uppercase tiny-label mb-2">Secure workspace access</p>
<h2 class="h3 mb-2">Sign in to your organization</h2>
<p class="text-secondary mb-0">Use a work email and role to enter an isolated tenant workspace.</p>
</div>
<?php if ($errors !== []): ?>
<div class="alert alert-danger border-0 small" role="alert">
<strong>Please fix the following:</strong>
<ul class="mb-0 mt-2 ps-3">
<?php foreach ($errors as $error): ?>
<li><?= e($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="post" class="row g-3">
<input type="hidden" name="csrf_token" value="<?= e($csrfToken) ?>">
<div class="col-12">
<label class="form-label" for="organization_name">Organization name</label>
<input class="form-control" id="organization_name" name="organization_name" type="text" placeholder="Northstar Holdings" value="<?= e($_POST['organization_name'] ?? '') ?>" required>
</div>
<div class="col-12">
<label class="form-label" for="organization_slug">Organization key</label>
<input class="form-control" id="organization_slug" name="organization_slug" type="text" placeholder="northstar-holdings" value="<?= e($_POST['organization_slug'] ?? '') ?>" required>
<div class="form-text">Used to isolate all OKR records for this organization.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="full_name">Full name</label>
<input class="form-control" id="full_name" name="full_name" type="text" placeholder="Morgan Lee" value="<?= e($_POST['full_name'] ?? '') ?>" required>
</div>
<div class="col-md-6">
<label class="form-label" for="email">Work email</label>
<input class="form-control" id="email" name="email" type="email" placeholder="morgan@northstar.com" value="<?= e($_POST['email'] ?? '') ?>" required>
</div>
<div class="col-12">
<label class="form-label" for="role">Role</label>
<select class="form-select" id="role" name="role" required>
<?php foreach (okr_roles() as $roleOption): ?>
<option value="<?= e($roleOption) ?>" <?= ($roleOption === ($_POST['role'] ?? 'Manager')) ? 'selected' : '' ?>><?= e($roleOption) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 d-grid gap-2 mt-2">
<button class="btn btn-brand" type="submit">Enter workspace</button>
<div class="small text-secondary">Roles included: Admin, CEO, Director, Manager, Team, Staff, plus Super Admin for SaaS oversight.</div>
</div>
</form>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="assets/js/main.js?v=<?= time() ?>" defer></script>
</body>
</html>

13
logout.php Normal file
View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool) $params['secure'], (bool) $params['httponly']);
}
session_destroy();
header('Location: login.php');
exit;

176
okr_bootstrap.php Normal file
View File

@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
@date_default_timezone_set('UTC');
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/db/config.php';
const OKR_ROLES = ['Super Admin', 'Admin', 'CEO', 'Director', 'Manager', 'Team', 'Staff'];
const OKR_APPROVER_ROLES = ['Super Admin', 'Admin', 'CEO', 'Director', 'Manager'];
function env_value(string $key, string $default = ''): string
{
$serverValue = $_SERVER[$key] ?? null;
if (is_string($serverValue) && $serverValue !== '') {
return $serverValue;
}
$envValue = getenv($key);
return is_string($envValue) && $envValue !== '' ? $envValue : $default;
}
function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function okr_app_name(): string
{
return env_value('PROJECT_NAME', 'Aligned OKR Cloud');
}
function okr_meta_description(): string
{
return env_value('PROJECT_DESCRIPTION', 'Multi-tenant OKR workspace for strategy, approvals, and score tracking.');
}
function okr_roles(): array
{
return OKR_ROLES;
}
function okr_is_approver(string $role): bool
{
return in_array($role, OKR_APPROVER_ROLES, true);
}
function okr_is_super_admin(): bool
{
return (($_SESSION['okr_user']['role'] ?? '') === 'Super Admin');
}
function okr_current_user(): array
{
if (empty($_SESSION['okr_user']) || !is_array($_SESSION['okr_user'])) {
header('Location: login.php');
exit;
}
return $_SESSION['okr_user'];
}
function okr_flash(string $type, string $message): void
{
$_SESSION['okr_flash'] = [
'type' => $type,
'message' => $message,
];
}
function okr_pull_flash(): ?array
{
$flash = $_SESSION['okr_flash'] ?? null;
unset($_SESSION['okr_flash']);
return is_array($flash) ? $flash : null;
}
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
{
$sessionToken = $_SESSION['okr_csrf'] ?? '';
$postedToken = $_POST['csrf_token'] ?? '';
if (!is_string($postedToken) || !hash_equals((string) $sessionToken, $postedToken)) {
throw new RuntimeException('Security validation failed. Please refresh and try again.');
}
}
function okr_scope_clause(string $alias = ''): string
{
$prefix = $alias !== '' ? $alias . '.' : '';
return okr_is_super_admin() ? '1=1' : $prefix . 'organization_slug = :organization_slug';
}
function okr_scope_params(array $user): array
{
return okr_is_super_admin() ? [] : [':organization_slug' => $user['organization_slug']];
}
function okr_calculate_score(float $currentValue, float $targetValue): float
{
if ($targetValue <= 0) {
return 0.0;
}
$score = ($currentValue / $targetValue) * 100;
return round(max(0, min(100, $score)), 1);
}
function okr_badge_class(string $state): string
{
return match ($state) {
'approved', 'completed', 'active' => 'badge-soft-success',
'pending_manager', 'submitted' => 'badge-soft-warning',
'rejected', 'needs_revision' => 'badge-soft-danger',
default => 'badge-soft-neutral',
};
}
function okr_notification_count(array $user): int
{
if (!okr_is_approver($user['role'])) {
return 0;
}
$params = okr_scope_params($user);
$stmt = db()->prepare('SELECT COUNT(*) FROM okr_items WHERE ' . okr_scope_clause() . ' AND approval_state = :approval_state');
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindValue(':approval_state', 'pending_manager');
$stmt->execute();
return (int) $stmt->fetchColumn();
}
function okr_ensure_schema(): void
{
db()->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS okr_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_name VARCHAR(120) NOT NULL,
organization_slug VARCHAR(120) NOT NULL,
owner_name VARCHAR(120) NOT NULL,
owner_email VARCHAR(160) NOT NULL,
owner_role VARCHAR(40) NOT NULL,
department_name VARCHAR(120) NOT NULL,
period_name VARCHAR(120) NOT NULL,
objective_title VARCHAR(255) NOT NULL,
key_result_title VARCHAR(255) NOT NULL,
description TEXT NULL,
target_value DECIMAL(10,2) NOT NULL DEFAULT 100.00,
current_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
score_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
status VARCHAR(40) NOT NULL DEFAULT 'draft',
approval_state VARCHAR(40) NOT NULL DEFAULT 'pending_manager',
manager_comment TEXT NULL,
created_by_email VARCHAR(160) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_scope (organization_slug, department_name, approval_state),
INDEX idx_owner (owner_email, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}

249
okr_detail.php Normal file
View File

@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/okr_bootstrap.php';
okr_ensure_schema();
$user = okr_current_user();
$scopeClause = okr_scope_clause();
$scopeParams = okr_scope_params($user);
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
okr_flash('danger', 'Select a valid OKR record.');
header('Location: index.php');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'review_okr') {
try {
okr_verify_csrf();
if (!okr_is_approver($user['role'])) {
throw new RuntimeException('Your current role cannot approve or reject OKRs in this release.');
}
$decision = trim((string) ($_POST['decision'] ?? 'update'));
$currentValue = (float) ($_POST['current_value'] ?? 0);
$targetValue = (float) ($_POST['target_value'] ?? 0);
$managerComment = trim((string) ($_POST['manager_comment'] ?? ''));
if ($targetValue <= 0) {
throw new RuntimeException('Target value must stay above 0.');
}
if ($currentValue < 0) {
throw new RuntimeException('Current value cannot be negative.');
}
$scorePercent = okr_calculate_score($currentValue, $targetValue);
$approvalState = 'pending_manager';
$status = 'submitted';
if ($decision === 'approve') {
$approvalState = 'approved';
$status = $scorePercent >= 100 ? 'completed' : 'active';
if ($managerComment === '') {
$managerComment = 'Approved and scored by ' . $user['role'] . '.';
}
} elseif ($decision === 'reject') {
$approvalState = 'rejected';
$status = 'needs_revision';
if ($managerComment === '') {
$managerComment = 'Rejected with feedback from ' . $user['role'] . '.';
}
} elseif ($managerComment === '') {
$managerComment = 'Progress updated by ' . $user['role'] . '.';
}
$sql = 'UPDATE okr_items SET current_value = :current_value, target_value = :target_value, score_percent = :score_percent, approval_state = :approval_state, status = :status, manager_comment = :manager_comment WHERE id = :id AND ' . $scopeClause;
$stmt = db()->prepare($sql);
$stmt->bindValue(':current_value', $currentValue);
$stmt->bindValue(':target_value', $targetValue);
$stmt->bindValue(':score_percent', $scorePercent);
$stmt->bindValue(':approval_state', $approvalState);
$stmt->bindValue(':status', $status);
$stmt->bindValue(':manager_comment', $managerComment);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
foreach ($scopeParams as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
okr_flash('success', 'OKR updated successfully.');
header('Location: okr_detail.php?id=' . $id);
exit;
} catch (Throwable $exception) {
okr_flash('danger', $exception->getMessage());
header('Location: okr_detail.php?id=' . $id);
exit;
}
}
$sql = 'SELECT * FROM okr_items WHERE id = :id AND ' . $scopeClause . ' LIMIT 1';
$stmt = db()->prepare($sql);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
foreach ($scopeParams as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
$item = $stmt->fetch();
if (!$item) {
okr_flash('danger', 'That OKR could not be found in your current scope.');
header('Location: index.php');
exit;
}
$projectName = okr_app_name();
$projectDescription = okr_meta_description();
$projectImageUrl = env_value('PROJECT_IMAGE_URL');
$flash = okr_pull_flash();
$csrfToken = okr_csrf_token();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($projectName) ?> · OKR detail</title>
<meta name="description" content="<?= e($projectDescription) ?>">
<?php if ($projectDescription !== ''): ?>
<meta property="og:description" content="<?= e($projectDescription) ?>">
<meta property="twitter:description" content="<?= e($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl !== ''): ?>
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
<?php endif; ?>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= time() ?>">
</head>
<body class="detail-shell">
<div class="container py-4 py-lg-5">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
<div>
<p class="tiny-label mb-2">OKR detail</p>
<h1 class="h3 mb-1"><?= e($item['objective_title']) ?></h1>
<p class="text-secondary mb-0"><?= e($item['organization_name']) ?> · <?= e($item['department_name']) ?> · <?= e($item['period_name']) ?></p>
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-secondary" href="index.php">Back to workspace</a>
<form method="post" action="logout.php">
<button class="btn btn-outline-danger" type="submit">Log out</button>
</form>
</div>
</div>
<?php if ($flash): ?>
<div class="alert alert-<?= e($flash['type']) ?> border-0 shadow-sm" role="alert">
<?= e($flash['message']) ?>
</div>
<?php endif; ?>
<div class="row g-4">
<div class="col-xl-8">
<section class="surface-card h-100">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
<div>
<p class="tiny-label mb-2">Key result</p>
<h2 class="h5 mb-1"><?= e($item['key_result_title']) ?></h2>
<p class="small text-secondary mb-0">Owner: <?= e($item['owner_name']) ?> · <?= e($item['owner_role']) ?> · <?= e($item['owner_email']) ?></p>
</div>
<span class="badge <?= e(okr_badge_class((string) $item['approval_state'])) ?>"><?= e($item['approval_state']) ?></span>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="surface-muted p-3 rounded-3 h-100">
<div class="small text-secondary mb-1">Current score</div>
<div class="h4 mb-0"><?= e((string) $item['score_percent']) ?>%</div>
</div>
</div>
<div class="col-md-4">
<div class="surface-muted p-3 rounded-3 h-100">
<div class="small text-secondary mb-1">Current value</div>
<div class="h4 mb-0"><?= e((string) $item['current_value']) ?></div>
</div>
</div>
<div class="col-md-4">
<div class="surface-muted p-3 rounded-3 h-100">
<div class="small text-secondary mb-1">Target value</div>
<div class="h4 mb-0"><?= e((string) $item['target_value']) ?></div>
</div>
</div>
</div>
<div class="mb-4">
<div class="d-flex justify-content-between small mb-1">
<span class="fw-semibold text-dark">Progress to target</span>
<span class="text-secondary"><?= e((string) $item['score_percent']) ?>%</span>
</div>
<div class="progress large-progress" role="progressbar" aria-valuenow="<?= e((string) $item['score_percent']) ?>" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar bg-success" style="width: <?= e((string) $item['score_percent']) ?>%"></div>
</div>
</div>
<div class="mb-4">
<div class="small text-secondary mb-2">Objective notes</div>
<div class="surface-muted rounded-3 p-3 small">
<?= nl2br(e($item['description'] ?: 'No additional notes supplied.')) ?>
</div>
</div>
<div>
<div class="small text-secondary mb-2">Latest reviewer comment</div>
<div class="surface-muted rounded-3 p-3 small">
<?= nl2br(e($item['manager_comment'] ?: 'No comments yet.')) ?>
</div>
</div>
</section>
</div>
<div class="col-xl-4">
<section class="surface-card mb-4">
<p class="tiny-label mb-2">Approval workflow</p>
<h2 class="h5 mb-3">Review and score</h2>
<form method="post" class="row g-3" id="okrReviewForm">
<input type="hidden" name="action" value="review_okr">
<input type="hidden" name="csrf_token" value="<?= e($csrfToken) ?>">
<div class="col-12">
<label class="form-label" for="detail_target_value">Target value</label>
<input class="form-control js-score-target" id="detail_target_value" name="target_value" type="number" min="1" step="0.1" value="<?= e((string) $item['target_value']) ?>" <?= okr_is_approver($user['role']) ? '' : 'disabled' ?> >
</div>
<div class="col-12">
<label class="form-label" for="detail_current_value">Current value</label>
<input class="form-control js-score-current" id="detail_current_value" name="current_value" type="number" min="0" step="0.1" value="<?= e((string) $item['current_value']) ?>" <?= okr_is_approver($user['role']) ? '' : 'disabled' ?> >
</div>
<div class="col-12">
<label class="form-label" for="manager_comment">Comment</label>
<textarea class="form-control" id="manager_comment" name="manager_comment" rows="4" <?= okr_is_approver($user['role']) ? '' : 'disabled' ?>><?= e((string) ($item['manager_comment'] ?? '')) ?></textarea>
</div>
<div class="col-12">
<label class="form-label">Projected score</label>
<div class="score-preview surface-muted">
<strong class="js-score-output"><?= e((string) $item['score_percent']) ?>%</strong>
<span class="small text-secondary">Recomputed live in the browser</span>
</div>
</div>
<?php if (okr_is_approver($user['role'])): ?>
<div class="col-12 d-grid gap-2">
<button class="btn btn-brand" type="submit" name="decision" value="approve">Approve and score</button>
<button class="btn btn-outline-secondary" type="submit" name="decision" value="update">Save progress only</button>
<button class="btn btn-outline-danger" type="submit" name="decision" value="reject">Reject with feedback</button>
</div>
<?php else: ?>
<div class="alert alert-light border small mb-0">You can view this record, but only leadership roles can change approval status in this first release.</div>
<?php endif; ?>
</form>
</section>
<section class="surface-card">
<p class="tiny-label mb-2">Audit snapshot</p>
<ul class="list-unstyled small mb-0 vstack gap-2 text-secondary">
<li><strong class="text-dark">Created:</strong> <?= e(date('M j, Y H:i', strtotime((string) $item['created_at']))) ?> UTC</li>
<li><strong class="text-dark">Updated:</strong> <?= e(date('M j, Y H:i', strtotime((string) $item['updated_at']))) ?> UTC</li>
<li><strong class="text-dark">Status:</strong> <?= e((string) $item['status']) ?></li>
<li><strong class="text-dark">Approval:</strong> <?= e((string) $item['approval_state']) ?></li>
</ul>
</section>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="assets/js/main.js?v=<?= time() ?>" defer></script>
</body>
</html>