diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..785bb39 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -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; -} \ No newline at end of file +.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; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..ab9774f 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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); }); }); diff --git a/cookiejar.txt b/cookiejar.txt new file mode 100644 index 0000000..c115dc2 --- /dev/null +++ b/cookiejar.txt @@ -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 diff --git a/db/migrations/001_create_okr_items.sql b/db/migrations/001_create_okr_items.sql new file mode 100644 index 0000000..04aac1b --- /dev/null +++ b/db/migrations/001_create_okr_items.sql @@ -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; diff --git a/index.php b/index.php index 7205f3d..a15e4c6 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,628 @@ = 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; ?> - - - New Style - - - - - - - - + + + <?= e($projectName) ?> · Workspace + + + + - - - - - + + + - - - - + + - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+ +
+ + +
+
+
+ +
+
Operational strategy workspace
+

Dashboard

+
+
+
+
+ +
+ + +
+
+ +
+
+
+

First MVP delivery

+

A working OKR workflow for one tenant-aware organization at a time.

+

Create a personal objective, view organization-wide progress, and move items through manager review with auto-scored key results.

+
+
+
+
Workspace
+
+
+
+
Role
+
+
+
+
Version
+
0.1 MVP
+
+
+
+ + +
+ +
+ + +
+
+
Total OKRs
+
+
Scoped to
+
+
+
Pending approvals
+
+
Queue for line-manager review
+
+
+
Approved items
+
+
Includes leadership auto-approvals
+
+
+
Average score
+
%
+
Calculated from key result progress
+
+
+ +
+
+
+
+
+

Corporate OKRs

+

Recent strategic objectives

+

A compact hierarchical view of the latest objectives and key results in scope.

+
+ Use the search field in the header to filter all table rows. +
+ +
+
No OKRs yet
+

Create your first objective in the My OKRs section to populate the dashboard.

+
+ + + +
+
+
+
+

Department OKRs

+

Distribution by department

+ +

Department insights appear after your team creates records.

+ +
+ +
+
+ + OKRs +
+
+
+
+
Average score %
+
+ +
+ +
+

Staff OKRs

+

Workflow completion

+
+
+
+
% of in-scope objectives are completed.
+
+
+
+ +
+
+
+
+
+

My OKRs

+

Create a new objective

+

This thin slice covers create → confirmation → list → detail → approval.

+
+
All writes use PDO prepared statements.
+
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ 0% + Calculated automatically +
+
+
+ + View current records +
+
+
+
+
+
+

Approval inbox

+

Items waiting for review

+ +
Only Manager, Director, CEO, Admin, and Super Admin roles can approve or reject submitted OKRs in this first release.
+ +
+
Inbox is clear
+

Pending approvals will appear here as staff submit new OKRs.

+
+ +
+ +
+
+
+
+
·
+
+
+
%
+
Current score
+
+
+ +
+ +
+ +
+
+
+ +
+
+
+

Shared list

+

In-scope OKR records

+

Each record opens a detail page for approvals, comments, and score updates.

+
+
Showing up to 24 most recent records.
+
+ +
+
Your workspace is ready for the first OKR
+

Create one above to activate the dashboard, approval inbox, and analytics cards.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
OwnerDepartmentObjectiveScoreStatusAction
+
+
+
+
+
+
+
+
+
+
%
+
Updated
+
+ +
+
+ Open +
+
+ +
+ +
+
+
+

Personal queue

+

Your latest submissions

+

Quick access to your own items inside the current organization scope.

+
+ Create another +
+
+ +
+
No personal OKRs created yet in this workspace.
+
+ + + + + +
+
+
+ +
-
- + + +
+
+

Notifications

+ +
+
+
+
Approval workload
+
item(s) are currently waiting for a line manager or leadership decision.
+
+
This initial delivery uses lightweight refreshes and contextual alerts. Real-time comment streams and richer notifications can be layered onto the same workflow next.
+
+
+ + + diff --git a/login.php b/login.php new file mode 100644 index 0000000..10d86e6 --- /dev/null +++ b/login.php @@ -0,0 +1,176 @@ + $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(); +?> + + + + + + <?= e($projectName) ?> · Secure sign in + + + + + + + + + + + + + +
+
+
+
+
+ Aligned OKR + Multi-tenant SaaS +
+
+

Strategic execution platform

+

Run one tenant-aware OKR workflow from sign-in to approval.

+

This initial release gives each organization an isolated workspace with a clean dashboard, scoped OKR records, manager approvals, and lightweight analytics.

+
+
+
+
+
Included now
+
My OKRs workflow
+

Create objectives, track key results, and route them into approval with automatic score calculation.

+
+
+
+
+
Ready for managers
+
Approval inbox
+

Managers, directors, CEOs, and super admins can review progress, leave feedback, and approve or reject.

+
+
+
+
+
Version 0.1 · Designed for secure internal planning and execution.
+
+
+
+
+

Secure workspace access

+

Sign in to your organization

+

Use a work email and role to enter an isolated tenant workspace.

+
+ + + + + +
+ +
+ + +
+
+ + +
Used to isolate all OKR records for this organization.
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Roles included: Admin, CEO, Director, Manager, Team, Staff, plus Super Admin for SaaS oversight.
+
+
+
+
+
+
+ + + + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..037218f --- /dev/null +++ b/logout.php @@ -0,0 +1,13 @@ + $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); +} diff --git a/okr_detail.php b/okr_detail.php new file mode 100644 index 0000000..b659001 --- /dev/null +++ b/okr_detail.php @@ -0,0 +1,249 @@ += 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(); +?> + + + + + + <?= e($projectName) ?> · OKR detail + + + + + + + + + + + + + +
+
+
+

OKR detail

+

+

· ·

+
+
+ Back to workspace +
+ +
+
+
+ + + + + +
+
+
+
+
+

Key result

+

+

Owner: · ·

+
+ +
+
+
+
+
Current score
+
%
+
+
+
+
+
Current value
+
+
+
+
+
+
Target value
+
+
+
+
+
+
+ Progress to target + % +
+
+
+
+
+
+
Objective notes
+
+ +
+
+
+
Latest reviewer comment
+
+ +
+
+
+
+
+
+

Approval workflow

+

Review and score

+
+ + +
+ + > +
+
+ + > +
+
+ + +
+
+ +
+ % + Recomputed live in the browser +
+
+ +
+ + + +
+ +
You can view this record, but only leadership roles can change approval status in this first release.
+ +
+
+
+

Audit snapshot

+
    +
  • Created: UTC
  • +
  • Updated: UTC
  • +
  • Status:
  • +
  • Approval:
  • +
+
+
+
+
+ + + +