From 765d998fa1c66cae6de50871fca7ae68354d0019 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 1 Apr 2026 10:36:51 +0000 Subject: [PATCH] V1 --- assets/css/custom.css | 911 +++++++++++------- assets/js/main.js | 185 +++- db/migrations/20260401_create_okr_entries.sql | 24 + feed.php | 30 + includes/okr_app.php | 664 +++++++++++++ index.php | 703 +++++++++++--- okr_detail.php | 374 +++++++ 7 files changed, 2372 insertions(+), 519 deletions(-) create mode 100644 db/migrations/20260401_create_okr_entries.sql create mode 100644 feed.php create mode 100644 includes/okr_app.php create mode 100644 okr_detail.php diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..e8ac0a8 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,608 @@ +:root { + --app-bg: #F1EFE3; + --app-surface: #FBFAF6; + --app-surface-strong: #FFFFFF; + --app-border: rgba(0, 0, 0, 0.10); + --app-border-strong: rgba(0, 0, 0, 0.16); + --app-text: #000000; + --app-muted: rgba(0, 0, 0, 0.62); + --app-primary: #68BB59; + --app-primary-strong: #32CD32; + --app-warning: #B88A1A; + --app-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 18px; + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + --spacing-3: 0.75rem; + --spacing-4: 1rem; + --spacing-5: 1.5rem; + --spacing-6: 2rem; +} + +* { + box-sizing: border-box; +} + +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; + background: var(--app-bg); + color: var(--app-text); + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + line-height: 1.5; } -.main-wrapper { +a { + color: inherit; +} + +.app-shell, +.detail-shell { + min-height: 100vh; +} + +.app-shell { + display: grid; + grid-template-columns: 100%; +} + +.sidebar-panel { + background: rgba(255, 255, 255, 0.4); + border-bottom: 1px solid var(--app-border); + padding: var(--spacing-5); display: flex; + flex-direction: column; + gap: var(--spacing-5); +} + +.brand-mark { + display: inline-flex; + align-items: center; + gap: 0.8rem; + color: var(--app-text); +} + +.brand-mark strong, +.profile-chip strong { + display: block; + font-size: 0.95rem; + font-weight: 600; +} + +.brand-mark small, +.profile-chip small { + display: block; + color: var(--app-muted); + font-size: 0.75rem; +} + +.brand-dot, +.profile-avatar { + display: inline-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); + width: 2.25rem; + height: 2.25rem; 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 { - font-size: 14px; + background: var(--app-primary); color: #fff; + font-weight: 700; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); +} + +.sidebar-copy, +.table-subtext, +.activity-meta, +.text-muted, +.meta-list span, +.meta-list dd span { + color: var(--app-muted) !important; +} + +.sidebar-nav { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.sidebar-link { + display: inline-flex; + align-items: center; + border: 1px solid var(--app-border); + padding: 0.55rem 0.8rem; + border-radius: var(--radius-sm); text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* 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; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; -} - -.table td { - background: #fff; - padding: 1rem; - border: none; -} - -.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; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; font-size: 0.9rem; + background: rgba(255, 255, 255, 0.65); + transition: border-color 0.2s ease, background-color 0.2s ease; } -.form-control { +.sidebar-link:hover, +.sidebar-link.active { + border-color: rgba(104, 187, 89, 0.45); + background: rgba(104, 187, 89, 0.14); +} + +.app-main { + display: flex; + flex-direction: column; + min-width: 0; +} + +.topbar, +.detail-topbar { + padding: var(--spacing-5); + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-4); +} + +.topbar { + border-bottom: 1px solid var(--app-border); + background: rgba(255, 255, 255, 0.55); + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: blur(10px); +} + +.topbar-tools { + display: flex; + align-items: center; + gap: var(--spacing-3); 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; + flex-wrap: wrap; } -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); +.search-shell { + flex: 1 1 220px; } -.header-container { +.profile-chip, +.notification-pill { + display: inline-flex; + align-items: center; + gap: 0.7rem; + padding: 0.55rem 0.8rem; + border-radius: var(--radius-sm); + border: 1px solid var(--app-border); + background: var(--app-surface); +} + +.compact-profile { + min-width: 0; +} + +.notification-pill { + font-size: 0.88rem; +} + +.app-content { + padding: var(--spacing-5); +} + +.page-section { + margin-bottom: var(--spacing-6); +} + +.surface-card, +.surface-subtle, +.hero-card, +.score-panel, +.comment-card, +.activity-item { + border: 1px solid var(--app-border); + background: var(--app-surface-strong); + border-radius: var(--radius-md); + box-shadow: var(--app-shadow); +} + +.surface-card { + padding: var(--spacing-5); +} + +.surface-subtle { + background: rgba(255, 255, 255, 0.7); +} + +.compact-card { + padding: var(--spacing-4); +} + +.hero-card { + display: grid; + gap: var(--spacing-5); + padding: var(--spacing-5); +} + +.hero-copy h2, +.page-title, +.section-title { + font-weight: 600; + letter-spacing: -0.02em; +} + +.page-title { + font-size: clamp(1.65rem, 3vw, 2.25rem); +} + +.hero-copy h2 { + font-size: clamp(1.5rem, 2.6vw, 2.1rem); + margin: 0.35rem 0 0.75rem; +} + +.hero-copy p { + max-width: 62ch; + color: var(--app-muted); + margin-bottom: 1rem; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-3); +} + +.hero-aside { + border: 1px solid var(--app-border); + border-radius: var(--radius-md); + padding: var(--spacing-4); + background: rgba(104, 187, 89, 0.08); +} + +.metric-inline { display: flex; justify-content: space-between; - align-items: center; + gap: var(--spacing-3); + padding: 0.7rem 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); } -.header-links { - display: flex; - gap: 1rem; +.metric-inline:last-child { + border-bottom: 0; } -.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); +.metric-inline span { + color: var(--app-muted); + font-size: 0.86rem; } -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; +.metric-inline strong, +.stat-value, +.score-panel strong { + font-size: 1.65rem; font-weight: 700; } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.section-kicker { + color: var(--app-primary); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; +.section-title { + font-size: 1.2rem; + margin: 0.3rem 0 0; } -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; +.section-header { + display: flex; + justify-content: space-between; + align-items: end; + gap: var(--spacing-4); + margin-bottom: var(--spacing-4); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: var(--spacing-4); +} + +.stat-card { + padding: var(--spacing-4); +} + +.stat-label { + font-size: 0.82rem; + color: var(--app-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.slim-progress { + height: 0.45rem; + margin-top: 0.75rem; + background: rgba(0, 0, 0, 0.06); +} + +.progress-bar { + background: var(--app-primary); +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; font-weight: 600; - width: 100%; - transition: all 0.3s ease; + padding: 0.35rem 0.65rem; + border-radius: 999px; + border: 1px solid transparent; } -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; +.status-draft { + color: #7c5b00; + background: rgba(184, 138, 26, 0.12); + border-color: rgba(184, 138, 26, 0.18); } -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); +.status-pending { + color: #7c5b00; + background: rgba(255, 193, 7, 0.14); + border-color: rgba(255, 193, 7, 0.22); } -.history-table { - width: 100%; +.status-approved { + color: #1e6b27; + background: rgba(104, 187, 89, 0.14); + border-color: rgba(104, 187, 89, 0.22); } -.history-table-time { - width: 15%; +.table-card { + padding: 0; + overflow: hidden; +} + +.app-table { + margin-bottom: 0; +} + +.app-table thead th { + padding: 0.95rem 1rem; + font-size: 0.74rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--app-muted); + border-bottom-color: var(--app-border); white-space: nowrap; - font-size: 0.85em; - color: #555; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.app-table tbody td { + padding: 1rem; + border-bottom-color: rgba(0, 0, 0, 0.06); + vertical-align: middle; } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.table-actions { + display: inline-flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.5rem; } -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file +.empty-state { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; + padding: var(--spacing-4); + border: 1px dashed var(--app-border-strong); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.8); +} + +.compact-empty { + margin: 1rem; +} + +.activity-list, +.comment-stream { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.activity-item, +.comment-card { + padding: 0.95rem 1rem; +} + +.activity-item:hover { + border-color: rgba(104, 187, 89, 0.35); +} + +.static-item:hover { + border-color: var(--app-border); +} + +.activity-topline { + display: flex; + justify-content: space-between; + align-items: start; + gap: 1rem; + margin-bottom: 0.35rem; + font-size: 0.86rem; +} + +.activity-topline span { + color: var(--app-muted); + white-space: nowrap; + font-size: 0.76rem; +} + +.activity-text { + color: var(--app-text); + font-size: 0.92rem; +} + +.key-result-row + .key-result-row { + padding-top: 0.2rem; +} + +.meta-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 0.9rem; +} + +.meta-grid dt { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--app-muted); + margin-bottom: 0.15rem; +} + +.meta-grid dd { + margin: 0; + font-weight: 600; +} + +.meta-grid dd span { + display: block; + margin-top: 0.15rem; + font-weight: 400; +} + +.department-card { + display: flex; + flex-direction: column; +} + +.department-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-3); +} + +.score-panel { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.3rem; + padding: var(--spacing-4); + background: rgba(104, 187, 89, 0.08); +} + +.score-panel span, +.score-panel small { + color: var(--app-muted); +} + +.footer-bar { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--spacing-3); + padding: 0 var(--spacing-5) var(--spacing-5); + color: var(--app-muted); + font-size: 0.84rem; +} + +.form-control, +.form-select, +textarea.form-control { + border-radius: var(--radius-sm); + border-color: var(--app-border); + min-height: 2.75rem; + background: #fff; + color: var(--app-text); +} + +.form-control:focus, +.form-select:focus, +.btn:focus, +.sidebar-link:focus, +.notification-pill:focus { + border-color: rgba(104, 187, 89, 0.55); + box-shadow: 0 0 0 0.2rem rgba(104, 187, 89, 0.16); +} + +.btn { + border-radius: 12px; + font-weight: 600; + padding: 0.7rem 1rem; +} + +.btn-success { + background: var(--app-primary); + border-color: var(--app-primary); + color: #fff; +} + +.btn-success:hover, +.btn-success:focus { + background: var(--app-primary-strong); + border-color: var(--app-primary-strong); + color: #fff; +} + +.btn-outline-dark, +.btn-outline-secondary, +.btn-dark { + box-shadow: none; +} + +.detail-content { + padding-top: 0; +} + +.is-hidden-search { + display: none !important; +} + +.toast-container { + z-index: 1080; +} + +@media (min-width: 768px) { + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 992px) { + .app-shell { + grid-template-columns: 280px minmax(0, 1fr); + } + + .sidebar-panel { + min-height: 100vh; + border-right: 1px solid var(--app-border); + border-bottom: 0; + position: sticky; + top: 0; + } + + .hero-card { + grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.8fr); + align-items: center; + } + + .stats-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..cb7c8ca 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,160 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const keyResultsContainer = document.getElementById('keyResultsContainer'); + const addKeyResultButton = document.querySelector('[data-add-key-result]'); + const searchInput = document.querySelector('[data-search-input]'); + const flash = document.querySelector('.app-flash'); + const feedContainers = [...document.querySelectorAll('[data-feed-url]')]; + const notificationCount = document.getElementById('notificationCount'); - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + const escapeHtml = (value = '') => String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const formatUtc = (value) => { + if (!value) return '—'; + const date = new Date(String(value).replace(' ', 'T') + 'Z'); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(date); }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + const showToast = (message, type = 'success') => { + if (!message || !window.bootstrap) return; - appendMessage(message, 'visitor'); - chatInput.value = ''; - - 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'); + let container = document.querySelector('.toast-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'toast-container position-fixed top-0 end-0 p-3'; + document.body.appendChild(container); } - }); + + const tone = type === 'danger' ? 'text-bg-dark' : 'text-bg-success'; + const toast = document.createElement('div'); + toast.className = `toast align-items-center border-0 ${tone}`; + toast.role = 'alert'; + toast.ariaLive = 'assertive'; + toast.ariaAtomic = 'true'; + toast.innerHTML = ` +
+
${escapeHtml(message)}
+ +
+ `; + container.appendChild(toast); + const instance = new bootstrap.Toast(toast, { delay: 3200 }); + instance.show(); + toast.addEventListener('hidden.bs.toast', () => toast.remove()); + }; + + if (flash) { + showToast(flash.dataset.flashMessage || '', flash.dataset.flashType || 'success'); + } + + if (addKeyResultButton && keyResultsContainer) { + addKeyResultButton.addEventListener('click', () => { + const row = document.createElement('div'); + row.className = 'key-result-row'; + row.innerHTML = ` +
+
+ + +
+
+ + +
+
+ +
+
+ `; + keyResultsContainer.appendChild(row); + }); + + keyResultsContainer.addEventListener('click', (event) => { + const button = event.target.closest('[data-remove-key-result]'); + if (!button) return; + const rows = keyResultsContainer.querySelectorAll('.key-result-row'); + if (rows.length <= 1) { + const input = rows[0]?.querySelector('input[name="key_result_title[]"]'); + if (input) input.focus(); + return; + } + button.closest('.key-result-row')?.remove(); + }); + } + + if (searchInput) { + const tables = [...document.querySelectorAll('[data-search-table]')]; + const applySearch = () => { + const term = searchInput.value.trim().toLowerCase(); + tables.forEach((table) => { + const rows = table.querySelectorAll('tbody tr'); + rows.forEach((row) => { + if (row.querySelector('.empty-state')) { + row.classList.remove('is-hidden-search'); + return; + } + const text = row.textContent.toLowerCase(); + row.classList.toggle('is-hidden-search', term !== '' && !text.includes(term)); + }); + }); + }; + searchInput.addEventListener('input', applySearch); + } + + const renderFeed = (notifications) => { + const html = notifications.length + ? notifications.map((item) => ` + +
+ ${escapeHtml(item.actor_name || 'System')} + ${escapeHtml(formatUtc(item.time))} +
+
${escapeHtml(item.message || '')}
+
${escapeHtml(item.objective_title || '')}
+
+ `).join('') + : ` +
+ No notifications yet. + Create the first OKR draft to start the activity stream. +
+ `; + + feedContainers.forEach((container) => { + container.innerHTML = html; + }); + if (notificationCount) { + notificationCount.textContent = String(notifications.length); + } + }; + + const refreshFeed = async () => { + if (feedContainers.length === 0) return; + const url = feedContainers[0].dataset.feedUrl; + if (!url) return; + try { + const response = await fetch(url, { headers: { 'X-Requested-With': 'fetch' } }); + if (!response.ok) return; + const data = await response.json(); + if (!data || data.success !== true || !Array.isArray(data.notifications)) return; + renderFeed(data.notifications); + } catch (error) { + console.error('Feed refresh failed', error); + } + }; + + if (feedContainers.length > 0) { + window.setInterval(refreshFeed, 20000); + } }); diff --git a/db/migrations/20260401_create_okr_entries.sql b/db/migrations/20260401_create_okr_entries.sql new file mode 100644 index 0000000..a72bc5f --- /dev/null +++ b/db/migrations/20260401_create_okr_entries.sql @@ -0,0 +1,24 @@ +-- Initial MVP slice: single-table OKR workflow storage +CREATE TABLE IF NOT EXISTS okr_entries ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + objective_title VARCHAR(190) NOT NULL, + owner_name VARCHAR(120) NOT NULL, + owner_email VARCHAR(160) NOT NULL, + owner_role VARCHAR(40) NOT NULL DEFAULT 'Staff', + department_name VARCHAR(120) NOT NULL, + period_label VARCHAR(60) NOT NULL, + approver_name VARCHAR(120) NOT NULL, + approver_level VARCHAR(40) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'Draft', + objective_score DECIMAL(5,2) NOT NULL DEFAULT 0, + key_results_json LONGTEXT NOT NULL, + comments_json LONGTEXT NULL, + activity_json LONGTEXT NULL, + submitted_at DATETIME NULL, + approved_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_department (department_name), + INDEX idx_owner_email (owner_email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/feed.php b/feed.php new file mode 100644 index 0000000..6d58ce6 --- /dev/null +++ b/feed.php @@ -0,0 +1,30 @@ + true, + 'notifications' => $notifications, + 'counts' => [ + 'total' => $metrics['total'], + 'draft' => $metrics['draft'], + 'pending' => $metrics['pending'], + 'approved' => $metrics['approved'], + ], + ], JSON_UNESCAPED_UNICODE); +} catch (Throwable $exception) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => 'Unable to load activity feed.', + ], JSON_UNESCAPED_UNICODE); +} diff --git a/includes/okr_app.php b/includes/okr_app.php new file mode 100644 index 0000000..d85c48a --- /dev/null +++ b/includes/okr_app.php @@ -0,0 +1,664 @@ + [ + 'key' => 'staff', + 'name' => 'Amina Staff', + 'email' => 'amina.staff@example.com', + 'role' => 'Staff', + 'level' => 'Staff', + 'department' => 'Operations', + ], + 'approver_team' => [ + 'key' => 'approver_team', + 'name' => 'Noah Team Lead', + 'email' => 'noah.team@example.com', + 'role' => 'Approver', + 'level' => 'Team', + 'department' => 'Operations', + ], + 'approver_manager' => [ + 'key' => 'approver_manager', + 'name' => 'David Manager', + 'email' => 'david.manager@example.com', + 'role' => 'Approver', + 'level' => 'Manager', + 'department' => 'Operations', + ], + 'approver_director' => [ + 'key' => 'approver_director', + 'name' => 'Lina Director', + 'email' => 'lina.director@example.com', + 'role' => 'Approver', + 'level' => 'Director', + 'department' => 'Strategy', + ], + 'approver_ceo' => [ + 'key' => 'approver_ceo', + 'name' => 'Joseph CEO', + 'email' => 'joseph.ceo@example.com', + 'role' => 'Approver', + 'level' => 'CEO', + 'department' => 'Executive', + ], + 'admin' => [ + 'key' => 'admin', + 'name' => 'Rita Admin', + 'email' => 'rita.admin@example.com', + 'role' => 'Admin', + 'level' => 'Admin', + 'department' => 'People Ops', + ], + ]; +} + +function okr_current_profile(): array +{ + $profiles = okr_profiles(); + $key = $_SESSION['okr_profile'] ?? 'staff'; + if (!isset($profiles[$key])) { + $key = 'staff'; + } + return $profiles[$key]; +} + +function okr_set_profile(string $key): void +{ + $profiles = okr_profiles(); + if (isset($profiles[$key])) { + $_SESSION['okr_profile'] = $key; + } +} + +function okr_csrf_token(): string +{ + if (empty($_SESSION['okr_csrf'])) { + $_SESSION['okr_csrf'] = bin2hex(random_bytes(16)); + } + return (string) $_SESSION['okr_csrf']; +} + +function okr_verify_csrf(): void +{ + $posted = $_POST['csrf_token'] ?? ''; + if (!hash_equals(okr_csrf_token(), (string) $posted)) { + throw new RuntimeException('Your session expired. Refresh the page and try again.'); + } +} + +function okr_flash(?string $type = null, ?string $message = null): ?array +{ + if ($type !== null && $message !== null) { + $_SESSION['okr_flash'] = ['type' => $type, 'message' => $message]; + return null; + } + + if (empty($_SESSION['okr_flash']) || !is_array($_SESSION['okr_flash'])) { + return null; + } + + $flash = $_SESSION['okr_flash']; + unset($_SESSION['okr_flash']); + return $flash; +} + +function okr_level_rank(string $level): int +{ + return match ($level) { + 'Team' => 1, + 'Manager' => 2, + 'Director' => 3, + 'CEO' => 4, + 'Admin' => 5, + default => 0, + }; +} + +function okr_is_admin(array $profile): bool +{ + return ($profile['role'] ?? '') === 'Admin'; +} + +function okr_is_approver(array $profile): bool +{ + return ($profile['role'] ?? '') === 'Approver' || okr_is_admin($profile); +} + +function okr_require_schema(): void +{ + static $ready = false; + if ($ready) { + return; + } + + $sql = <<exec($sql); + $ready = true; +} + +function okr_clean_text(string $value, int $max = 190): string +{ + $value = trim($value); + if ($value === '') { + return ''; + } + return function_exists('mb_substr') ? mb_substr($value, 0, $max) : substr($value, 0, $max); +} + +function okr_parse_json_field(?string $value): array +{ + if ($value === null || trim($value) === '') { + return []; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; +} + +function okr_safe_score(mixed $value): float +{ + if ($value === null || $value === '') { + return 0.0; + } + $score = (float) $value; + if ($score < 0) { + $score = 0; + } + if ($score > 100) { + $score = 100; + } + return round($score, 2); +} + +function okr_effective_score(array $keyResult, string $status): float +{ + if ($status === 'Approved' && isset($keyResult['manager_score']) && $keyResult['manager_score'] !== null && $keyResult['manager_score'] !== '') { + return okr_safe_score($keyResult['manager_score']); + } + return okr_safe_score($keyResult['owner_score'] ?? 0); +} + +function okr_calculate_objective_score(array $keyResults, string $status): float +{ + if ($keyResults === []) { + return 0.0; + } + $total = 0.0; + $count = 0; + foreach ($keyResults as $keyResult) { + $total += okr_effective_score($keyResult, $status); + $count++; + } + return $count > 0 ? round($total / $count, 1) : 0.0; +} + +function okr_activity_item(array $profile, string $message, string $kind = 'update'): array +{ + return [ + 'time' => gmdate('Y-m-d H:i:s'), + 'actor_name' => $profile['name'] ?? 'System', + 'actor_role' => $profile['role'] ?? 'System', + 'actor_level' => $profile['level'] ?? '', + 'kind' => $kind, + 'message' => $message, + ]; +} + +function okr_comment_item(array $profile, string $message): array +{ + return [ + 'time' => gmdate('Y-m-d H:i:s'), + 'actor_name' => $profile['name'] ?? 'System', + 'actor_role' => $profile['role'] ?? 'System', + 'message' => $message, + ]; +} + +function okr_prepare_entry(array $row): array +{ + $row['key_results'] = okr_parse_json_field($row['key_results_json'] ?? '[]'); + $row['comments'] = okr_parse_json_field($row['comments_json'] ?? '[]'); + $row['activity'] = okr_parse_json_field($row['activity_json'] ?? '[]'); + $row['objective_score'] = (float) ($row['objective_score'] ?? 0); + $row['key_result_count'] = count($row['key_results']); + $completed = 0; + foreach ($row['key_results'] as $keyResult) { + if (okr_effective_score($keyResult, (string) ($row['status'] ?? 'Draft')) >= 70) { + $completed++; + } + } + $row['completed_key_results'] = $completed; + return $row; +} + +function okr_fetch_entries(?string $status = null): array +{ + okr_require_schema(); + $sql = 'SELECT * FROM okr_entries'; + $params = []; + if ($status !== null && in_array($status, ['Draft', 'Pending', 'Approved'], true)) { + $sql .= ' WHERE status = :status'; + $params[':status'] = $status; + } + $sql .= ' ORDER BY updated_at DESC, id DESC'; + $stmt = db()->prepare($sql); + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value); + } + $stmt->execute(); + return array_map('okr_prepare_entry', $stmt->fetchAll()); +} + +function okr_fetch_entry(int $id): ?array +{ + okr_require_schema(); + $stmt = db()->prepare('SELECT * FROM okr_entries WHERE id = :id LIMIT 1'); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->execute(); + $row = $stmt->fetch(); + return $row ? okr_prepare_entry($row) : null; +} + +function okr_can_edit_owner(array $entry, array $profile): bool +{ + return okr_is_admin($profile) || (($profile['email'] ?? '') === ($entry['owner_email'] ?? '')); +} + +function okr_can_review(array $entry, array $profile): bool +{ + if (okr_is_admin($profile)) { + return true; + } + if (!okr_is_approver($profile)) { + return false; + } + return okr_level_rank((string) ($profile['level'] ?? '')) >= okr_level_rank((string) ($entry['approver_level'] ?? '')); +} + +function okr_normalize_key_results(array $titles, array $dueDates = [], array $ownerScores = [], array $managerScores = []): array +{ + $results = []; + foreach ($titles as $index => $title) { + $cleanTitle = okr_clean_text((string) $title, 190); + if ($cleanTitle === '') { + continue; + } + $dueDate = okr_clean_text((string) ($dueDates[$index] ?? ''), 20); + $results[] = [ + 'title' => $cleanTitle, + 'due_date' => $dueDate, + 'owner_score' => okr_safe_score($ownerScores[$index] ?? 0), + 'manager_score' => ($managerScores[$index] ?? '') === '' ? null : okr_safe_score($managerScores[$index]), + ]; + } + return $results; +} + +function okr_create_entry(array $payload, array $actor): int +{ + okr_require_schema(); + $keyResults = okr_normalize_key_results( + $payload['key_result_title'] ?? [], + $payload['key_result_due'] ?? [] + ); + + if (count($keyResults) === 0) { + throw new RuntimeException('Add at least one key result before saving the objective.'); + } + + $objectiveTitle = okr_clean_text((string) ($payload['objective_title'] ?? ''), 190); + $ownerName = okr_clean_text((string) ($payload['owner_name'] ?? ''), 120); + $ownerEmail = filter_var((string) ($payload['owner_email'] ?? ''), FILTER_VALIDATE_EMAIL) ?: ''; + $departmentName = okr_clean_text((string) ($payload['department_name'] ?? ''), 120); + $periodLabel = okr_clean_text((string) ($payload['period_label'] ?? ''), 60); + $approverName = okr_clean_text((string) ($payload['approver_name'] ?? ''), 120); + $approverLevel = okr_clean_text((string) ($payload['approver_level'] ?? ''), 40); + + if ($objectiveTitle === '' || $ownerName === '' || $ownerEmail === '' || $departmentName === '' || $periodLabel === '' || $approverName === '' || $approverLevel === '') { + throw new RuntimeException('Complete all required fields before creating the objective.'); + } + + if (!in_array($approverLevel, ['Team', 'Manager', 'Director', 'CEO'], true)) { + throw new RuntimeException('Select a valid approver level.'); + } + + $activity = [okr_activity_item($actor, 'Draft objective created with ' . count($keyResults) . ' key result(s).', 'created')]; + $score = okr_calculate_objective_score($keyResults, 'Draft'); + + $stmt = db()->prepare( + 'INSERT INTO okr_entries ( + objective_title, owner_name, owner_email, owner_role, department_name, period_label, + approver_name, approver_level, status, objective_score, key_results_json, comments_json, activity_json + ) VALUES ( + :objective_title, :owner_name, :owner_email, :owner_role, :department_name, :period_label, + :approver_name, :approver_level, :status, :objective_score, :key_results_json, :comments_json, :activity_json + )' + ); + $stmt->bindValue(':objective_title', $objectiveTitle); + $stmt->bindValue(':owner_name', $ownerName); + $stmt->bindValue(':owner_email', $ownerEmail); + $stmt->bindValue(':owner_role', (string) ($actor['role'] ?? 'Staff')); + $stmt->bindValue(':department_name', $departmentName); + $stmt->bindValue(':period_label', $periodLabel); + $stmt->bindValue(':approver_name', $approverName); + $stmt->bindValue(':approver_level', $approverLevel); + $stmt->bindValue(':status', 'Draft'); + $stmt->bindValue(':objective_score', $score); + $stmt->bindValue(':key_results_json', json_encode($keyResults, JSON_UNESCAPED_UNICODE)); + $stmt->bindValue(':comments_json', json_encode([], JSON_UNESCAPED_UNICODE)); + $stmt->bindValue(':activity_json', json_encode($activity, JSON_UNESCAPED_UNICODE)); + $stmt->execute(); + + return (int) db()->lastInsertId(); +} + +function okr_update_entry_record(array $entry, array $keyResults, array $comments, array $activity, string $status, ?string $submittedAt = null, ?string $approvedAt = null): void +{ + $score = okr_calculate_objective_score($keyResults, $status); + $stmt = db()->prepare( + 'UPDATE okr_entries + SET status = :status, + objective_score = :objective_score, + key_results_json = :key_results_json, + comments_json = :comments_json, + activity_json = :activity_json, + submitted_at = :submitted_at, + approved_at = :approved_at + WHERE id = :id' + ); + $stmt->bindValue(':status', $status); + $stmt->bindValue(':objective_score', $score); + $stmt->bindValue(':key_results_json', json_encode($keyResults, JSON_UNESCAPED_UNICODE)); + $stmt->bindValue(':comments_json', json_encode($comments, JSON_UNESCAPED_UNICODE)); + $stmt->bindValue(':activity_json', json_encode($activity, JSON_UNESCAPED_UNICODE)); + $stmt->bindValue(':submitted_at', $submittedAt); + $stmt->bindValue(':approved_at', $approvedAt); + $stmt->bindValue(':id', (int) $entry['id'], PDO::PARAM_INT); + $stmt->execute(); +} + +function okr_update_owner_scores(int $id, array $ownerScores, array $actor): void +{ + $entry = okr_fetch_entry($id); + if (!$entry) { + throw new RuntimeException('Objective not found.'); + } + if (!okr_can_edit_owner($entry, $actor) && !okr_is_admin($actor)) { + throw new RuntimeException('You can only update self-scores for your own objectives.'); + } + + $keyResults = $entry['key_results']; + foreach ($keyResults as $index => &$keyResult) { + if (array_key_exists($index, $ownerScores)) { + $keyResult['owner_score'] = okr_safe_score($ownerScores[$index]); + } + } + unset($keyResult); + + $entry['activity'][] = okr_activity_item($actor, 'Owner scores updated.', 'score'); + okr_update_entry_record( + $entry, + $keyResults, + $entry['comments'], + $entry['activity'], + (string) $entry['status'], + $entry['submitted_at'] ?: null, + $entry['approved_at'] ?: null + ); +} + +function okr_submit_entry(int $id, array $actor): void +{ + $entry = okr_fetch_entry($id); + if (!$entry) { + throw new RuntimeException('Objective not found.'); + } + if (!okr_can_edit_owner($entry, $actor) && !okr_is_admin($actor)) { + throw new RuntimeException('Only the objective owner or admin can submit this OKR.'); + } + + $status = 'Pending'; + $approvedAt = null; + if (($entry['approver_level'] ?? '') === 'CEO') { + $status = 'Approved'; + $approvedAt = gmdate('Y-m-d H:i:s'); + foreach ($entry['key_results'] as &$keyResult) { + $keyResult['manager_score'] = okr_safe_score($keyResult['owner_score'] ?? 0); + } + unset($keyResult); + $entry['activity'][] = okr_activity_item($actor, 'Submitted and auto-approved because the approver level is CEO.', 'approved'); + } else { + $entry['activity'][] = okr_activity_item($actor, 'Submitted for approval to ' . $entry['approver_name'] . '.', 'submitted'); + } + + okr_update_entry_record( + $entry, + $entry['key_results'], + $entry['comments'], + $entry['activity'], + $status, + gmdate('Y-m-d H:i:s'), + $approvedAt + ); +} + +function okr_review_entry(int $id, string $decision, array $managerScores, string $note, array $actor): void +{ + $entry = okr_fetch_entry($id); + if (!$entry) { + throw new RuntimeException('Objective not found.'); + } + if (!okr_can_review($entry, $actor)) { + throw new RuntimeException('Your current role does not have approval authority for this objective.'); + } + + $keyResults = $entry['key_results']; + foreach ($keyResults as $index => &$keyResult) { + if (array_key_exists($index, $managerScores)) { + $keyResult['manager_score'] = okr_safe_score($managerScores[$index]); + } + } + unset($keyResult); + + $decision = $decision === 'reject' ? 'reject' : 'approve'; + $status = $decision === 'approve' ? 'Approved' : 'Draft'; + $approvedAt = $decision === 'approve' ? gmdate('Y-m-d H:i:s') : null; + $message = $decision === 'approve' ? 'Approved and scored by ' . ($actor['name'] ?? 'approver') . '.' : 'Returned to draft with feedback from ' . ($actor['name'] ?? 'approver') . '.'; + if ($note !== '') { + $message .= ' Note: ' . $note; + $entry['comments'][] = okr_comment_item($actor, $note); + } + $entry['activity'][] = okr_activity_item($actor, $message, $decision === 'approve' ? 'approved' : 'rejected'); + + okr_update_entry_record( + $entry, + $keyResults, + $entry['comments'], + $entry['activity'], + $status, + $entry['submitted_at'] ?: gmdate('Y-m-d H:i:s'), + $approvedAt + ); +} + +function okr_add_comment(int $id, string $message, array $actor): void +{ + $entry = okr_fetch_entry($id); + if (!$entry) { + throw new RuntimeException('Objective not found.'); + } + $message = okr_clean_text($message, 500); + if ($message === '') { + throw new RuntimeException('Write a short comment before posting.'); + } + $entry['comments'][] = okr_comment_item($actor, $message); + $entry['activity'][] = okr_activity_item($actor, 'Added a comment.', 'comment'); + okr_update_entry_record( + $entry, + $entry['key_results'], + $entry['comments'], + $entry['activity'], + (string) $entry['status'], + $entry['submitted_at'] ?: null, + $entry['approved_at'] ?: null + ); +} + +function okr_delete_entry(int $id, array $actor): void +{ + $entry = okr_fetch_entry($id); + if (!$entry) { + throw new RuntimeException('Objective not found.'); + } + if (($entry['status'] ?? '') !== 'Draft') { + throw new RuntimeException('Only draft objectives can be deleted in this first MVP slice.'); + } + if (!okr_can_edit_owner($entry, $actor) && !okr_is_admin($actor)) { + throw new RuntimeException('You can only delete your own draft objective.'); + } + $stmt = db()->prepare('DELETE FROM okr_entries WHERE id = :id'); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->execute(); +} + +function okr_collect_notifications(array $entries, int $limit = 8): array +{ + $notifications = []; + foreach ($entries as $entry) { + foreach (($entry['activity'] ?? []) as $activity) { + $notifications[] = [ + 'time' => $activity['time'] ?? '', + 'message' => $activity['message'] ?? '', + 'actor_name' => $activity['actor_name'] ?? 'System', + 'kind' => $activity['kind'] ?? 'update', + 'objective_title' => $entry['objective_title'] ?? '', + 'objective_id' => (int) ($entry['id'] ?? 0), + ]; + } + } + + usort($notifications, static function (array $left, array $right): int { + return strcmp((string) ($right['time'] ?? ''), (string) ($left['time'] ?? '')); + }); + + return array_slice($notifications, 0, $limit); +} + +function okr_dashboard_metrics(array $entries): array +{ + $metrics = [ + 'total' => count($entries), + 'draft' => 0, + 'pending' => 0, + 'approved' => 0, + 'average_score' => 0, + 'approval_rate' => 0, + 'departments' => [], + ]; + + $scoreTotal = 0.0; + foreach ($entries as $entry) { + $status = (string) ($entry['status'] ?? 'Draft'); + if ($status === 'Draft') { + $metrics['draft']++; + } elseif ($status === 'Pending') { + $metrics['pending']++; + } elseif ($status === 'Approved') { + $metrics['approved']++; + } + $scoreTotal += (float) ($entry['objective_score'] ?? 0); + $department = (string) ($entry['department_name'] ?? 'Unassigned'); + if (!isset($metrics['departments'][$department])) { + $metrics['departments'][$department] = [ + 'name' => $department, + 'count' => 0, + 'approved' => 0, + 'average_score' => 0, + 'score_total' => 0.0, + ]; + } + $metrics['departments'][$department]['count']++; + if ($status === 'Approved') { + $metrics['departments'][$department]['approved']++; + } + $metrics['departments'][$department]['score_total'] += (float) ($entry['objective_score'] ?? 0); + } + + if ($metrics['total'] > 0) { + $metrics['average_score'] = round($scoreTotal / $metrics['total'], 1); + $metrics['approval_rate'] = round(($metrics['approved'] / $metrics['total']) * 100, 1); + } + + foreach ($metrics['departments'] as &$department) { + $department['average_score'] = $department['count'] > 0 ? round($department['score_total'] / $department['count'], 1) : 0.0; + unset($department['score_total']); + } + unset($department); + + uasort($metrics['departments'], static fn(array $a, array $b): int => $b['count'] <=> $a['count']); + + return $metrics; +} + +function okr_redirect(string $path): never +{ + header('Location: ' . $path); + exit; +} + +function okr_time_label(?string $utc): string +{ + if (!$utc) { + return '—'; + } + $time = strtotime($utc . ' UTC'); + if ($time === false) { + return '—'; + } + return gmdate('M j, Y · H:i', $time) . ' UTC'; +} diff --git a/index.php b/index.php index 7205f3d..fe6a39a 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,585 @@ getMessage()); + okr_redirect('index.php'); + } +} + +$profile = okr_current_profile(); +$allEntries = okr_fetch_entries(); +$metrics = okr_dashboard_metrics($allEntries); +$notifications = okr_collect_notifications($allEntries, 10); +$reviewQueue = array_values(array_filter($allEntries, static fn(array $entry): bool => $entry['status'] === 'Pending')); +$myEntries = array_values(array_filter($allEntries, static fn(array $entry): bool => okr_can_edit_owner($entry, okr_current_profile()) || okr_is_admin(okr_current_profile()))); +$departments = $metrics['departments']; +$flash = okr_flash(); +$projectName = project_name(); +$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? project_description(); +$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; +$cssVersion = is_file(__DIR__ . '/assets/css/custom.css') ? (string) filemtime(__DIR__ . '/assets/css/custom.css') : (string) time(); +$jsVersion = is_file(__DIR__ . '/assets/js/main.js') ? (string) filemtime(__DIR__ . '/assets/js/main.js') : (string) time(); +$approvedPercent = $metrics['total'] > 0 ? (int) round(($metrics['approved'] / $metrics['total']) * 100) : 0; +$pendingPercent = $metrics['total'] > 0 ? (int) round(($metrics['pending'] / $metrics['total']) * 100) : 0; +$draftPercent = $metrics['total'] > 0 ? max(0, 100 - $approvedPercent - $pendingPercent) : 0; +$roleLabels = [ + 'Staff' => 'Staff', + 'Approver' => 'Approver', + 'Admin' => 'Admin', +]; ?> - - - New Style - + + + <?= e($projectName) ?> · OKR 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

+
+ + +
+
+
+
Initial MVP slice
+

One-button OKR workflow

+
+
+
+ + +
+ + Notifications + + +
+ +
+ + +
+
+
+
+ +
+
+
+
+
Secure workflow preview
+

Draft, submit, score, comment, and notify from one calm workspace.

+

This first delivery gives you a functional OKR path: staff can create objectives and key results, submit once, and approvers can score and approve with comments and visible activity.

+ +
+
+
+ Average objective score + % +
+
+ Approval rate + % +
+
+ Active departments + +
+
+
+ +
+
+
Total objectives
+
+
+
+
+
Draft
+
+
+
+
+
Pending approval
+
+
+
+
+
Approved
+
+
+
+
+
+ +
+
+
+
+
+
+
Create & submit
+

New objective draft

+
+ Saved as Draft first +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
Key results
+
Set measurable outcomes. CEO-level approvals will auto-approve on submit.
+
+ +
+
+ +
+
+
+ + > +
+
+ + +
+
+ +
+
+
+ +
+
+ +
+
Server-side validation, PDO writes, and activity logging are enabled for this workflow slice.
+ +
+
+
+
+ +
+
+
+
+
Live activity
+

Notifications and recent actions

+
+ Visible to all users +
+
+ +
+ No notifications yet. + Create the first OKR draft to start the activity stream. +
+ + + +
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
+
+
Personal workspace
+

My OKRs

+
+
List, detail, submit, and delete draft objectives.
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ObjectiveDepartmentPeriodStatusScoreApproverAction
+
+ No personal OKRs yet. + Use the draft form above to create your first objective. +
+
+ +
+
% + +
+
+
+ View + +
+ + + + + +
+
+ + + + +
+ +
+
+
+
+
+ +
+
+
+
Approval workflow
+

Review queue

+
+
Approvers can approve, reject, and rescore from the detail screen.
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ObjectiveOwnerDepartmentSubmittedApprover levelAction
+
+ Queue is clear. + Once an objective is submitted it will appear here for line-manager review. +
+
+ +
% owner score
+
+ +
+
+ Review +
+
+
+
+ +
+
+
+
Department OKRs
+

Portfolio overview by department

+
+
A thin corporate view to show coverage, approval health, and average scoring.
+
+
+ +
+
+ No departments yet. + Departments will appear automatically as OKRs are created. +
+
+ + +
+
+
+ + objectives +
+
+
+
Approved
+
+
+
+
Average score
+
%
+
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
Staff OKRs
+

All objectives

+
+
Search, scan statuses, and open detail pages from one place.
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ObjectiveOwnerStatusScoreUpdatedDetail
+
+ Nothing to review yet. + Create the first objective to populate the staff view. +
+
+ +
·
+
%Open
+
+
+
+
+ +
+ Version MVP-0.1 · OKR workflow slice + © +
-
-
- Page updated: (UTC) -
+ + + +
+ + + + diff --git a/okr_detail.php b/okr_detail.php new file mode 100644 index 0000000..a150429 --- /dev/null +++ b/okr_detail.php @@ -0,0 +1,374 @@ +getMessage()); + okr_redirect('okr_detail.php?id=' . $targetId); + } +} + +$entry = okr_fetch_entry($id); +if (!$entry) { + okr_flash('danger', 'Objective not found.'); + okr_redirect('index.php'); +} + +$flash = okr_flash(); +$projectName = project_name(); +$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? project_description(); +$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; +$cssVersion = is_file(__DIR__ . '/assets/css/custom.css') ? (string) filemtime(__DIR__ . '/assets/css/custom.css') : (string) time(); +$jsVersion = is_file(__DIR__ . '/assets/js/main.js') ? (string) filemtime(__DIR__ . '/assets/js/main.js') : (string) time(); +$canEditOwner = okr_can_edit_owner($entry, $profile) || okr_is_admin($profile); +$canReview = okr_can_review($entry, $profile); +$notifications = okr_collect_notifications(okr_fetch_entries(), 8); +?> + + + + + + <?= e($projectName) ?> · <?= e($entry['objective_title']) ?> + + + + + + + + + + + + + + + + +
+
+
+ ← Back to workspace +

+
+ + · + Owner: +
+
+
+ +
+ + +
+
+
+ +
+
+
+
+
+
+
+
Objective summary
+

+
+
+
Owner
+
+
+
+
Approver
+
level
+
+
+
Submitted
+
+
+
+
Approved
+
+
+
+
+
+
+ Objective score + % + / key results above 70% +
+
+
+
+ +
+
+
+
Scoring matrix
+

Key results and scores

+
+ +
+ + + + +
+ +
+
+ + + + + + + + + + + + $keyResult): ?> + + + + + + + + + +
Key resultDue dateOwner scoreManager scoreEffective
+ + %%
+
+
+ + +
+
Owner action
+

Update self-scores

+
+ + + + $keyResult): ?> +
+
+ +
Owner score before manager approval.
+
+
+ +
+
+ +
+
You can refine progress at any time; the objective score recalculates automatically.
+ +
+
+
+ + + +
+
Approver action
+

Approve or return with feedback

+
+ + + + $keyResult): ?> +
+
+ +
Owner score: %
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+
+ + +
+
Discussion
+

Comments

+
+ + + +
+ + +
+
+ +
+
+
+ +
+ No comments yet. + Comments are shared and visible alongside the approval trail. +
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
Permissions
+

Current access

+
+ + + + +
+
+ +
+
Recent activity
+

Objective timeline

+
+ +
+
+ + +
+
+
+
+ +
+
+ +
+
Global notifications
+

What everyone can see

+
+ +
+ No notifications yet. + Workspace activity will show up here automatically. +
+ + + +
+ + +
+
+
+
+ + +
+
+ + +
+
Cleanup
+

Delete draft

+

Delete is limited to draft objectives in this first iteration to keep the workflow safe.

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