Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
765d998fa1 V1 2026-04-01 10:36:51 +00:00
7 changed files with 2372 additions and 519 deletions

View File

@ -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 { 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; margin: 0;
min-height: 100vh; 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; 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; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; width: 2.25rem;
width: 100%; height: 2.25rem;
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%; border-radius: 50%;
filter: blur(80px); background: var(--app-primary);
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;
color: #fff; 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; 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; 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%; width: 100%;
padding: 0.75rem 1rem; flex-wrap: wrap;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
background: #fff;
transition: all 0.3s ease;
box-sizing: border-box;
} }
.form-control:focus { .search-shell {
outline: none; flex: 1 1 220px;
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
} }
.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; display: flex;
justify-content: space-between; 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 { .metric-inline:last-child {
display: flex; border-bottom: 0;
gap: 1rem;
} }
.admin-card { .metric-inline span {
background: rgba(255, 255, 255, 0.6); color: var(--app-muted);
padding: 2rem; font-size: 0.86rem;
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);
} }
.admin-card h3 { .metric-inline strong,
margin-top: 0; .stat-value,
margin-bottom: 1.5rem; .score-panel strong {
font-size: 1.65rem;
font-weight: 700; font-weight: 700;
} }
.btn-delete { .section-kicker {
background: #dc3545; color: var(--app-primary);
color: white; font-size: 0.76rem;
border: none; font-weight: 700;
padding: 0.25rem 0.5rem; letter-spacing: 0.08em;
border-radius: 4px; text-transform: uppercase;
cursor: pointer;
} }
.btn-add { .section-title {
background: #212529; font-size: 1.2rem;
color: white; margin: 0.3rem 0 0;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
} }
.btn-save { .section-header {
background: #0088cc; display: flex;
color: white; justify-content: space-between;
border: none; align-items: end;
padding: 0.8rem 1.5rem; gap: var(--spacing-4);
border-radius: 12px; margin-bottom: var(--spacing-4);
cursor: pointer; }
.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; font-weight: 600;
width: 100%; padding: 0.35rem 0.65rem;
transition: all 0.3s ease; border-radius: 999px;
border: 1px solid transparent;
} }
.webhook-url { .status-draft {
font-size: 0.85em; color: #7c5b00;
color: #555; background: rgba(184, 138, 26, 0.12);
margin-top: 0.5rem; border-color: rgba(184, 138, 26, 0.18);
} }
.history-table-container { .status-pending {
overflow-x: auto; color: #7c5b00;
background: rgba(255, 255, 255, 0.4); background: rgba(255, 193, 7, 0.14);
padding: 1rem; border-color: rgba(255, 193, 7, 0.22);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
} }
.history-table { .status-approved {
width: 100%; color: #1e6b27;
background: rgba(104, 187, 89, 0.14);
border-color: rgba(104, 187, 89, 0.22);
} }
.history-table-time { .table-card {
width: 15%; 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; white-space: nowrap;
font-size: 0.85em;
color: #555;
} }
.history-table-user { .app-table tbody td {
width: 35%; padding: 1rem;
background: rgba(255, 255, 255, 0.3); border-bottom-color: rgba(0, 0, 0, 0.06);
border-radius: 8px; vertical-align: middle;
padding: 8px;
} }
.history-table-ai { .table-actions {
width: 50%; display: inline-flex;
background: rgba(255, 255, 255, 0.5); justify-content: flex-end;
border-radius: 8px; flex-wrap: wrap;
padding: 8px; gap: 0.5rem;
} }
.no-messages { .empty-state {
text-align: center; display: flex;
color: #777; 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));
}
} }

View File

@ -1,39 +1,160 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form'); const keyResultsContainer = document.getElementById('keyResultsContainer');
const chatInput = document.getElementById('chat-input'); const addKeyResultButton = document.querySelector('[data-add-key-result]');
const chatMessages = document.getElementById('chat-messages'); 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 escapeHtml = (value = '') => String(value)
const msgDiv = document.createElement('div'); .replace(/&/g, '&')
msgDiv.classList.add('message', sender); .replace(/</g, '&lt;')
msgDiv.textContent = text; .replace(/>/g, '&gt;')
chatMessages.appendChild(msgDiv); .replace(/"/g, '&quot;')
chatMessages.scrollTop = chatMessages.scrollHeight; .replace(/'/g, '&#039;');
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) => { const showToast = (message, type = 'success') => {
e.preventDefault(); if (!message || !window.bootstrap) return;
const message = chatInput.value.trim();
if (!message) return;
appendMessage(message, 'visitor'); let container = document.querySelector('.toast-container');
chatInput.value = ''; if (!container) {
container = document.createElement('div');
try { container.className = 'toast-container position-fixed top-0 end-0 p-3';
const response = await fetch('api/chat.php', { document.body.appendChild(container);
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');
} }
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 = `
<div class="d-flex">
<div class="toast-body">${escapeHtml(message)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
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 = `
<div class="row g-2 align-items-end">
<div class="col-md-8">
<label class="form-label">Key result</label>
<input class="form-control" name="key_result_title[]" placeholder="Improve weekly OKR review completion rate">
</div>
<div class="col-md-3">
<label class="form-label">Due date</label>
<input class="form-control" type="date" name="key_result_due[]">
</div>
<div class="col-md-1">
<button class="btn btn-outline-secondary w-100" type="button" data-remove-key-result aria-label="Remove key result">×</button>
</div>
</div>
`;
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) => `
<a href="okr_detail.php?id=${encodeURIComponent(item.objective_id)}" class="activity-item text-decoration-none">
<div class="activity-topline">
<strong>${escapeHtml(item.actor_name || 'System')}</strong>
<span>${escapeHtml(formatUtc(item.time))}</span>
</div>
<div class="activity-text">${escapeHtml(item.message || '')}</div>
<div class="activity-meta">${escapeHtml(item.objective_title || '')}</div>
</a>
`).join('')
: `
<div class="empty-state compact-empty">
<strong>No notifications yet.</strong>
<span>Create the first OKR draft to start the activity stream.</span>
</div>
`;
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);
}
}); });

View File

@ -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;

30
feed.php Normal file
View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/okr_app.php';
header('Content-Type: application/json; charset=utf-8');
try {
okr_require_schema();
$entries = okr_fetch_entries();
$metrics = okr_dashboard_metrics($entries);
$notifications = okr_collect_notifications($entries, 8);
echo json_encode([
'success' => 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);
}

664
includes/okr_app.php Normal file
View File

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

689
index.php
View File

@ -1,150 +1,585 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; require_once __DIR__ . '/includes/okr_app.php';
$now = date('Y-m-d H:i:s');
okr_require_schema();
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
okr_verify_csrf();
$profile = okr_current_profile();
$action = (string) ($_POST['action_type'] ?? '');
switch ($action) {
case 'switch_profile':
okr_set_profile((string) ($_POST['profile_key'] ?? 'staff'));
okr_flash('success', 'Workspace role preview switched successfully.');
okr_redirect('index.php');
break;
case 'create_okr':
$entryId = okr_create_entry($_POST, $profile);
okr_flash('success', 'Objective saved as draft. You can now refine scores or submit it for approval.');
okr_redirect('okr_detail.php?id=' . $entryId);
break;
case 'submit_okr':
okr_submit_entry((int) ($_POST['id'] ?? 0), $profile);
okr_flash('success', 'Objective moved into the approval workflow.');
okr_redirect((string) ($_POST['redirect_to'] ?? 'index.php'));
break;
case 'delete_okr':
okr_delete_entry((int) ($_POST['id'] ?? 0), $profile);
okr_flash('success', 'Draft objective deleted.');
okr_redirect('index.php');
break;
default:
throw new RuntimeException('Unknown action.');
}
} catch (Throwable $exception) {
okr_flash('danger', $exception->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',
];
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title> <title><?= e($projectName) ?> · OKR Workspace</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?> <?php if ($projectDescription): ?>
<!-- Meta description --> <meta name="description" content="<?= e((string) $projectDescription) ?>" />
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' /> <meta property="og:description" content="<?= e((string) $projectDescription) ?>" />
<!-- Open Graph meta tags --> <meta property="twitter:description" content="<?= e((string) $projectDescription) ?>" />
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?> <?php endif; ?>
<?php if ($projectImageUrl): ?> <?php if ($projectImageUrl): ?>
<!-- Open Graph image --> <meta property="og:image" content="<?= e((string) $projectImageUrl) ?>" />
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta property="twitter:image" content="<?= e((string) $projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?> <?php endif; ?>
<meta name="theme-color" content="#68BB59" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<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">
:root { <link rel="stylesheet" href="assets/css/custom.css?v=<?= e($cssVersion) ?>">
--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>
</head> </head>
<body> <body>
<main> <div class="app-shell">
<div class="card"> <aside class="sidebar-panel">
<h1>Analyzing your requirements and generating your website…</h1> <div>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <a href="index.php" class="brand-mark text-decoration-none">
<span class="sr-only">Loading…</span> <span class="brand-dot"></span>
<div>
<strong><?= e($projectName) ?></strong>
<small>OKR Workspace</small>
</div> </div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p> </a>
<p class="hint">This page will update automatically as the plan is implemented.</p> <p class="sidebar-copy">Simple, seamless objective planning with one-click submission, approval, scoring, and transparent updates.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div> </div>
<nav class="sidebar-nav" aria-label="Primary navigation">
<a class="sidebar-link active" href="#dashboard">Dashboard</a>
<a class="sidebar-link" href="#my-okrs">My OKRs</a>
<a class="sidebar-link" href="#review-queue">Review Queue</a>
<a class="sidebar-link" href="#department-okrs">Department OKRs</a>
<a class="sidebar-link" href="#staff-okrs">Staff OKRs</a>
<a class="sidebar-link" href="#activity">Notifications</a>
</nav>
<div class="surface-card compact-card mt-3">
<div class="section-kicker">Workspace role preview</div>
<h2 class="section-title h6 mb-2">Act as any role in the workflow</h2>
<form method="post" class="vstack gap-2">
<input type="hidden" name="csrf_token" value="<?= e(okr_csrf_token()) ?>">
<input type="hidden" name="action_type" value="switch_profile">
<label for="profile_key" class="form-label small text-uppercase text-muted mb-0">Current role</label>
<select class="form-select form-select-sm" id="profile_key" name="profile_key">
<?php foreach (okr_profiles() as $key => $workspaceProfile): ?>
<option value="<?= e($key) ?>" <?= $key === $profile['key'] ? 'selected' : '' ?>>
<?= e($workspaceProfile['name']) ?> · <?= e($workspaceProfile['role']) ?><?= $workspaceProfile['level'] !== $workspaceProfile['role'] ? ' · ' . e($workspaceProfile['level']) : '' ?>
</option>
<?php endforeach; ?>
</select>
<button class="btn btn-dark btn-sm" type="submit">Switch preview</button>
</form>
<div class="meta-list mt-3">
<span><strong><?= e($profile['name']) ?></strong></span>
<span><?= e($profile['department']) ?> · <?= e($roleLabels[$profile['role']] ?? $profile['role']) ?></span>
<span><?= e($profile['email']) ?></span>
</div>
</div>
</aside>
<div class="app-main">
<header class="topbar">
<div>
<div class="section-kicker">Initial MVP slice</div>
<h1 class="page-title mb-0">One-button OKR workflow</h1>
</div>
<div class="topbar-tools">
<div class="search-shell">
<label class="visually-hidden" for="globalSearch">Search objectives</label>
<input id="globalSearch" type="search" class="form-control" placeholder="Search objectives, people, departments" data-search-input>
</div>
<a href="#activity" class="notification-pill text-decoration-none">
Notifications
<span class="badge text-bg-dark" id="notificationCount"><?= count($notifications) ?></span>
</a>
<div class="profile-chip">
<span class="profile-avatar"><?= e(substr($profile['name'], 0, 1)) ?></span>
<div>
<strong><?= e($profile['name']) ?></strong>
<small><?= e($profile['role']) ?><?= $profile['level'] !== $profile['role'] ? ' · ' . e($profile['level']) : '' ?></small>
</div>
</div>
</div>
</header>
<main class="app-content">
<section id="dashboard" class="page-section">
<div class="hero-card surface-card">
<div class="hero-copy">
<div class="section-kicker">Secure workflow preview</div>
<h2>Draft, submit, score, comment, and notify from one calm workspace.</h2>
<p>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.</p>
<div class="hero-actions">
<a href="#create-okr" class="btn btn-success">Create OKR</a>
<a href="#review-queue" class="btn btn-outline-dark">Open queue</a>
</div>
</div>
<div class="hero-aside">
<div class="metric-inline">
<span>Average objective score</span>
<strong><?= e(number_format($metrics['average_score'], 1)) ?>%</strong>
</div>
<div class="metric-inline">
<span>Approval rate</span>
<strong><?= e(number_format($metrics['approval_rate'], 1)) ?>%</strong>
</div>
<div class="metric-inline">
<span>Active departments</span>
<strong><?= e((string) count($departments)) ?></strong>
</div>
</div>
</div>
<div class="stats-grid mt-4">
<article class="surface-card stat-card">
<div class="stat-label">Total objectives</div>
<div class="stat-value"><?= e((string) $metrics['total']) ?></div>
<div class="progress slim-progress"><div class="progress-bar bg-dark" style="width: 100%"></div></div>
</article>
<article class="surface-card stat-card">
<div class="stat-label">Draft</div>
<div class="stat-value"><?= e((string) $metrics['draft']) ?></div>
<div class="progress slim-progress"><div class="progress-bar" style="width: <?= $draftPercent ?>%"></div></div>
</article>
<article class="surface-card stat-card">
<div class="stat-label">Pending approval</div>
<div class="stat-value"><?= e((string) $metrics['pending']) ?></div>
<div class="progress slim-progress"><div class="progress-bar bg-warning" style="width: <?= $pendingPercent ?>%"></div></div>
</article>
<article class="surface-card stat-card">
<div class="stat-label">Approved</div>
<div class="stat-value"><?= e((string) $metrics['approved']) ?></div>
<div class="progress slim-progress"><div class="progress-bar bg-success" style="width: <?= $approvedPercent ?>%"></div></div>
</article>
</div>
</section>
<section class="page-section" id="workflow-overview">
<div class="row g-4 align-items-stretch">
<div class="col-xl-7" id="create-okr">
<div class="surface-card h-100">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<div class="section-kicker">Create & submit</div>
<h2 class="section-title">New objective draft</h2>
</div>
<span class="status-pill status-draft">Saved as Draft first</span>
</div>
<form method="post" class="vstack gap-3">
<input type="hidden" name="csrf_token" value="<?= e(okr_csrf_token()) ?>">
<input type="hidden" name="action_type" value="create_okr">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label" for="objective_title">Objective title</label>
<input class="form-control" id="objective_title" name="objective_title" maxlength="190" placeholder="Increase team execution confidence" required>
</div>
<div class="col-md-4">
<label class="form-label" for="period_label">Period</label>
<input class="form-control" id="period_label" name="period_label" placeholder="Q2 2026" required>
</div>
<div class="col-md-6">
<label class="form-label" for="owner_name">Owner name</label>
<input class="form-control" id="owner_name" name="owner_name" maxlength="120" value="<?= e($profile['name']) ?>" required>
</div>
<div class="col-md-6">
<label class="form-label" for="owner_email">Owner email</label>
<input class="form-control" id="owner_email" type="email" name="owner_email" maxlength="160" value="<?= e($profile['email']) ?>" required>
</div>
<div class="col-md-6">
<label class="form-label" for="department_name">Department</label>
<input class="form-control" id="department_name" name="department_name" maxlength="120" value="<?= e($profile['department']) ?>" required>
</div>
<div class="col-md-3">
<label class="form-label" for="approver_name">Line manager</label>
<input class="form-control" id="approver_name" name="approver_name" maxlength="120" placeholder="David Manager" required>
</div>
<div class="col-md-3">
<label class="form-label" for="approver_level">Approver level</label>
<select class="form-select" id="approver_level" name="approver_level" required>
<option value="Manager">Manager</option>
<option value="Director">Director</option>
<option value="CEO">CEO</option>
<option value="Team">Team</option>
</select>
</div>
</div>
<div class="surface-subtle p-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<div class="section-kicker">Key results</div>
<div class="small text-muted">Set measurable outcomes. CEO-level approvals will auto-approve on submit.</div>
</div>
<button class="btn btn-outline-dark btn-sm" type="button" data-add-key-result>Add key result</button>
</div>
<div class="vstack gap-3" id="keyResultsContainer">
<?php for ($i = 0; $i < 2; $i++): ?>
<div class="key-result-row">
<div class="row g-2 align-items-end">
<div class="col-md-8">
<label class="form-label">Key result</label>
<input class="form-control" name="key_result_title[]" placeholder="Ship approval-ready OKR review in under 2 clicks" <?= $i === 0 ? 'required' : '' ?>>
</div>
<div class="col-md-3">
<label class="form-label">Due date</label>
<input class="form-control" type="date" name="key_result_due[]">
</div>
<div class="col-md-1">
<button class="btn btn-outline-secondary w-100" type="button" data-remove-key-result aria-label="Remove key result">×</button>
</div>
</div>
</div>
<?php endfor; ?>
</div>
</div>
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
<div class="small text-muted">Server-side validation, PDO writes, and activity logging are enabled for this workflow slice.</div>
<button type="submit" class="btn btn-success">Save draft</button>
</div>
</form>
</div>
</div>
<div class="col-xl-5" id="activity">
<div class="surface-card h-100 d-flex flex-column">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<div class="section-kicker">Live activity</div>
<h2 class="section-title">Notifications and recent actions</h2>
</div>
<span class="notification-pill small">Visible to all users</span>
</div>
<div class="activity-list" id="activityFeed" data-feed-url="feed.php">
<?php if ($notifications === []): ?>
<div class="empty-state">
<strong>No notifications yet.</strong>
<span>Create the first OKR draft to start the activity stream.</span>
</div>
<?php else: ?>
<?php foreach ($notifications as $notification): ?>
<a href="okr_detail.php?id=<?= (int) $notification['objective_id'] ?>" class="activity-item text-decoration-none">
<div class="activity-topline">
<strong><?= e($notification['actor_name']) ?></strong>
<span><?= e(okr_time_label((string) $notification['time'])) ?></span>
</div>
<div class="activity-text"><?= e($notification['message']) ?></div>
<div class="activity-meta"><?= e($notification['objective_title']) ?></div>
</a>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>
</section>
<section id="my-okrs" class="page-section">
<div class="section-header">
<div>
<div class="section-kicker">Personal workspace</div>
<h2 class="section-title">My OKRs</h2>
</div>
<div class="small text-muted">List, detail, submit, and delete draft objectives.</div>
</div>
<div class="surface-card table-card">
<div class="table-responsive">
<table class="table align-middle app-table" data-search-table>
<thead>
<tr>
<th>Objective</th>
<th>Department</th>
<th>Period</th>
<th>Status</th>
<th>Score</th>
<th>Approver</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
<?php if ($myEntries === []): ?>
<tr>
<td colspan="7">
<div class="empty-state compact-empty">
<strong>No personal OKRs yet.</strong>
<span>Use the draft form above to create your first objective.</span>
</div>
</td>
</tr>
<?php else: ?>
<?php foreach ($myEntries as $entry): ?>
<tr>
<td>
<strong><?= e($entry['objective_title']) ?></strong>
<div class="table-subtext"><?= e($entry['key_result_count'] . ' key results') ?></div>
</td>
<td><?= e($entry['department_name']) ?></td>
<td><?= e($entry['period_label']) ?></td>
<td><span class="status-pill status-<?= strtolower($entry['status']) ?>"><?= e($entry['status']) ?></span></td>
<td><?= e(number_format((float) $entry['objective_score'], 1)) ?>%</td>
<td>
<?= e($entry['approver_name']) ?>
<div class="table-subtext"><?= e($entry['approver_level']) ?></div>
</td>
<td class="text-end">
<div class="table-actions">
<a href="okr_detail.php?id=<?= (int) $entry['id'] ?>" class="btn btn-sm btn-outline-dark">View</a>
<?php if ($entry['status'] === 'Draft'): ?>
<form method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= e(okr_csrf_token()) ?>">
<input type="hidden" name="action_type" value="submit_okr">
<input type="hidden" name="id" value="<?= (int) $entry['id'] ?>">
<input type="hidden" name="redirect_to" value="index.php#my-okrs">
<button type="submit" class="btn btn-sm btn-success">Submit</button>
</form>
<form method="post" class="d-inline" onsubmit="return confirm('Delete this draft objective?');">
<input type="hidden" name="csrf_token" value="<?= e(okr_csrf_token()) ?>">
<input type="hidden" name="action_type" value="delete_okr">
<input type="hidden" name="id" value="<?= (int) $entry['id'] ?>">
<button type="submit" class="btn btn-sm btn-outline-secondary">Delete</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</section>
<section id="review-queue" class="page-section">
<div class="section-header">
<div>
<div class="section-kicker">Approval workflow</div>
<h2 class="section-title">Review queue</h2>
</div>
<div class="small text-muted">Approvers can approve, reject, and rescore from the detail screen.</div>
</div>
<div class="surface-card table-card">
<div class="table-responsive">
<table class="table align-middle app-table" data-search-table>
<thead>
<tr>
<th>Objective</th>
<th>Owner</th>
<th>Department</th>
<th>Submitted</th>
<th>Approver level</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
<?php if ($reviewQueue === []): ?>
<tr>
<td colspan="6">
<div class="empty-state compact-empty">
<strong>Queue is clear.</strong>
<span>Once an objective is submitted it will appear here for line-manager review.</span>
</div>
</td>
</tr>
<?php else: ?>
<?php foreach ($reviewQueue as $entry): ?>
<tr>
<td>
<strong><?= e($entry['objective_title']) ?></strong>
<div class="table-subtext"><?= e(number_format((float) $entry['objective_score'], 1)) ?>% owner score</div>
</td>
<td>
<?= e($entry['owner_name']) ?>
<div class="table-subtext"><?= e($entry['owner_email']) ?></div>
</td>
<td><?= e($entry['department_name']) ?></td>
<td><?= e(okr_time_label((string) $entry['submitted_at'])) ?></td>
<td><?= e($entry['approver_level']) ?></td>
<td class="text-end">
<a href="okr_detail.php?id=<?= (int) $entry['id'] ?>" class="btn btn-sm btn-dark">Review</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</section>
<section id="department-okrs" class="page-section">
<div class="section-header">
<div>
<div class="section-kicker">Department OKRs</div>
<h2 class="section-title">Portfolio overview by department</h2>
</div>
<div class="small text-muted">A thin corporate view to show coverage, approval health, and average scoring.</div>
</div>
<div class="row g-3">
<?php if ($departments === []): ?>
<div class="col-12">
<div class="surface-card empty-state compact-empty">
<strong>No departments yet.</strong>
<span>Departments will appear automatically as OKRs are created.</span>
</div>
</div>
<?php else: ?>
<?php foreach ($departments as $department): ?>
<div class="col-md-6 col-xl-4">
<article class="surface-card department-card h-100">
<div class="department-header">
<strong><?= e($department['name']) ?></strong>
<span><?= e((string) $department['count']) ?> objectives</span>
</div>
<dl class="meta-list meta-grid mt-3">
<div>
<dt>Approved</dt>
<dd><?= e((string) $department['approved']) ?></dd>
</div>
<div>
<dt>Average score</dt>
<dd><?= e(number_format((float) $department['average_score'], 1)) ?>%</dd>
</div>
</dl>
<div class="progress slim-progress mt-3">
<div class="progress-bar bg-success" style="width: <?= $department['count'] > 0 ? (int) round(($department['approved'] / $department['count']) * 100) : 0 ?>%"></div>
</div>
</article>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
<section id="staff-okrs" class="page-section">
<div class="section-header">
<div>
<div class="section-kicker">Staff OKRs</div>
<h2 class="section-title">All objectives</h2>
</div>
<div class="small text-muted">Search, scan statuses, and open detail pages from one place.</div>
</div>
<div class="surface-card table-card">
<div class="table-responsive">
<table class="table align-middle app-table" data-search-table>
<thead>
<tr>
<th>Objective</th>
<th>Owner</th>
<th>Status</th>
<th>Score</th>
<th>Updated</th>
<th class="text-end">Detail</th>
</tr>
</thead>
<tbody>
<?php if ($allEntries === []): ?>
<tr>
<td colspan="6">
<div class="empty-state compact-empty">
<strong>Nothing to review yet.</strong>
<span>Create the first objective to populate the staff view.</span>
</div>
</td>
</tr>
<?php else: ?>
<?php foreach ($allEntries as $entry): ?>
<tr>
<td>
<strong><?= e($entry['objective_title']) ?></strong>
<div class="table-subtext"><?= e($entry['department_name']) ?> · <?= e($entry['period_label']) ?></div>
</td>
<td><?= e($entry['owner_name']) ?></td>
<td><span class="status-pill status-<?= strtolower($entry['status']) ?>"><?= e($entry['status']) ?></span></td>
<td><?= e(number_format((float) $entry['objective_score'], 1)) ?>%</td>
<td><?= e(okr_time_label((string) $entry['updated_at'])) ?></td>
<td class="text-end"><a href="okr_detail.php?id=<?= (int) $entry['id'] ?>" class="btn btn-sm btn-outline-dark">Open</a></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</section>
</main> </main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC) <footer class="footer-bar">
<span>Version MVP-0.1 · OKR workflow slice</span>
<span>© <?= date('Y') ?> <?= e($projectName) ?></span>
</footer> </footer>
</div>
</div>
<?php if ($flash): ?>
<div class="app-flash" data-flash-type="<?= e((string) $flash['type']) ?>" data-flash-message="<?= e((string) $flash['message']) ?>"></div>
<?php endif; ?>
<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=<?= e($jsVersion) ?>" defer></script>
</body> </body>
</html> </html>

374
okr_detail.php Normal file
View File

@ -0,0 +1,374 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/okr_app.php';
okr_require_schema();
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
$profile = okr_current_profile();
$id = (int) ($_GET['id'] ?? 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
okr_verify_csrf();
$action = (string) ($_POST['action_type'] ?? '');
$targetId = (int) ($_POST['id'] ?? $id);
switch ($action) {
case 'update_owner_scores':
okr_update_owner_scores($targetId, $_POST['owner_score'] ?? [], $profile);
okr_flash('success', 'Owner scores updated.');
break;
case 'submit_okr':
okr_submit_entry($targetId, $profile);
okr_flash('success', 'Objective submitted into the approval workflow.');
break;
case 'review_okr':
$note = trim((string) ($_POST['review_note'] ?? ''));
okr_review_entry($targetId, (string) ($_POST['decision'] ?? 'approve'), $_POST['manager_score'] ?? [], $note, $profile);
okr_flash('success', 'Review decision saved.');
break;
case 'add_comment':
okr_add_comment($targetId, (string) ($_POST['comment_message'] ?? ''), $profile);
okr_flash('success', 'Comment posted.');
break;
case 'delete_okr':
okr_delete_entry($targetId, $profile);
okr_flash('success', 'Draft objective deleted.');
okr_redirect('index.php#my-okrs');
break;
default:
throw new RuntimeException('Unknown action.');
}
okr_redirect('okr_detail.php?id=' . $targetId);
} catch (Throwable $exception) {
okr_flash('danger', $exception->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);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><?= e($projectName) ?> · <?= e($entry['objective_title']) ?></title>
<?php if ($projectDescription): ?>
<meta name="description" content="<?= e((string) $projectDescription) ?>" />
<meta property="og:description" content="<?= e((string) $projectDescription) ?>" />
<meta property="twitter:description" content="<?= e((string) $projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= e((string) $projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= e((string) $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;500;600;700&display=swap" rel="stylesheet">
<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=<?= e($cssVersion) ?>">
</head>
<body>
<div class="detail-shell">
<header class="detail-topbar">
<div>
<a href="index.php" class="text-decoration-none small text-uppercase text-muted"> Back to workspace</a>
<h1 class="page-title mt-2 mb-1"><?= e($entry['objective_title']) ?></h1>
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="status-pill status-<?= strtolower($entry['status']) ?>"><?= e($entry['status']) ?></span>
<span class="small text-muted"><?= e($entry['department_name']) ?> · <?= e($entry['period_label']) ?></span>
<span class="small text-muted">Owner: <?= e($entry['owner_name']) ?></span>
</div>
</div>
<div class="profile-chip compact-profile">
<span class="profile-avatar"><?= e(substr($profile['name'], 0, 1)) ?></span>
<div>
<strong><?= e($profile['name']) ?></strong>
<small><?= e($profile['role']) ?><?= $profile['level'] !== $profile['role'] ? ' · ' . e($profile['level']) : '' ?></small>
</div>
</div>
</header>
<main class="app-content detail-content">
<section class="page-section pt-0">
<div class="row g-4">
<div class="col-xl-8">
<div class="surface-card mb-4">
<div class="row g-3 align-items-start">
<div class="col-lg-8">
<div class="section-kicker">Objective summary</div>
<h2 class="section-title"><?= e($entry['objective_title']) ?></h2>
<div class="meta-list meta-grid mt-3">
<div>
<dt>Owner</dt>
<dd><?= e($entry['owner_name']) ?><span><?= e($entry['owner_email']) ?></span></dd>
</div>
<div>
<dt>Approver</dt>
<dd><?= e($entry['approver_name']) ?><span><?= e($entry['approver_level']) ?> level</span></dd>
</div>
<div>
<dt>Submitted</dt>
<dd><?= e(okr_time_label((string) $entry['submitted_at'])) ?></dd>
</div>
<div>
<dt>Approved</dt>
<dd><?= e(okr_time_label((string) $entry['approved_at'])) ?></dd>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="score-panel">
<span>Objective score</span>
<strong><?= e(number_format((float) $entry['objective_score'], 1)) ?>%</strong>
<small><?= e((string) $entry['completed_key_results']) ?> / <?= e((string) $entry['key_result_count']) ?> key results above 70%</small>
</div>
</div>
</div>
</div>
<div class="surface-card mb-4">
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
<div>
<div class="section-kicker">Scoring matrix</div>
<h2 class="section-title">Key results and scores</h2>
</div>
<?php if ($canEditOwner && $entry['status'] === 'Draft'): ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= e(okr_csrf_token()) ?>">
<input type="hidden" name="action_type" value="submit_okr">
<input type="hidden" name="id" value="<?= (int) $entry['id'] ?>">
<button class="btn btn-success" type="submit">Submit for approval</button>
</form>
<?php endif; ?>
</div>
<div class="table-responsive">
<table class="table align-middle app-table">
<thead>
<tr>
<th>Key result</th>
<th>Due date</th>
<th>Owner score</th>
<th>Manager score</th>
<th>Effective</th>
</tr>
</thead>
<tbody>
<?php foreach ($entry['key_results'] as $index => $keyResult): ?>
<tr>
<td>
<strong><?= e((string) $keyResult['title']) ?></strong>
</td>
<td><?= e((string) ($keyResult['due_date'] ?: '—')) ?></td>
<td><?= e(number_format((float) ($keyResult['owner_score'] ?? 0), 1)) ?>%</td>
<td><?= $keyResult['manager_score'] === null ? '—' : e(number_format((float) $keyResult['manager_score'], 1)) . '%' ?></td>
<td><?= e(number_format(okr_effective_score($keyResult, (string) $entry['status']), 1)) ?>%</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if ($canEditOwner): ?>
<div class="surface-card mb-4">
<div class="section-kicker">Owner action</div>
<h2 class="section-title">Update self-scores</h2>
<form method="post" class="vstack gap-3">
<input type="hidden" name="csrf_token" value="<?= e(okr_csrf_token()) ?>">
<input type="hidden" name="action_type" value="update_owner_scores">
<input type="hidden" name="id" value="<?= (int) $entry['id'] ?>">
<?php foreach ($entry['key_results'] as $index => $keyResult): ?>
<div class="row g-3 align-items-center">
<div class="col-md-8">
<label class="form-label mb-1"><?= e((string) $keyResult['title']) ?></label>
<div class="small text-muted">Owner score before manager approval.</div>
</div>
<div class="col-md-4">
<input class="form-control" type="number" min="0" max="100" step="0.1" name="owner_score[<?= $index ?>]" value="<?= e((string) $keyResult['owner_score']) ?>">
</div>
</div>
<?php endforeach; ?>
<div class="d-flex justify-content-between align-items-center gap-3">
<div class="small text-muted">You can refine progress at any time; the objective score recalculates automatically.</div>
<button class="btn btn-outline-dark" type="submit">Save self-scores</button>
</div>
</form>
</div>
<?php endif; ?>
<?php if ($canReview && $entry['status'] !== 'Approved'): ?>
<div class="surface-card mb-4">
<div class="section-kicker">Approver action</div>
<h2 class="section-title">Approve or return with feedback</h2>
<form method="post" class="vstack gap-3">
<input type="hidden" name="csrf_token" value="<?= e(okr_csrf_token()) ?>">
<input type="hidden" name="action_type" value="review_okr">
<input type="hidden" name="id" value="<?= (int) $entry['id'] ?>">
<?php foreach ($entry['key_results'] as $index => $keyResult): ?>
<div class="row g-3 align-items-center">
<div class="col-md-8">
<label class="form-label mb-1"><?= e((string) $keyResult['title']) ?></label>
<div class="small text-muted">Owner score: <?= e(number_format((float) $keyResult['owner_score'], 1)) ?>%</div>
</div>
<div class="col-md-4">
<input class="form-control" type="number" min="0" max="100" step="0.1" name="manager_score[<?= $index ?>]" value="<?= e((string) ($keyResult['manager_score'] ?? $keyResult['owner_score'])) ?>">
</div>
</div>
<?php endforeach; ?>
<div>
<label class="form-label" for="review_note">Feedback note</label>
<textarea class="form-control" rows="3" id="review_note" name="review_note" maxlength="500" placeholder="Add guidance or approval context"></textarea>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<button class="btn btn-outline-secondary" type="submit" name="decision" value="reject">Return to draft</button>
<button class="btn btn-success" type="submit" name="decision" value="approve">Approve and score</button>
</div>
</form>
</div>
<?php endif; ?>
<div class="surface-card">
<div class="section-kicker">Discussion</div>
<h2 class="section-title">Comments</h2>
<form method="post" class="vstack gap-3 mb-4">
<input type="hidden" name="csrf_token" value="<?= e(okr_csrf_token()) ?>">
<input type="hidden" name="action_type" value="add_comment">
<input type="hidden" name="id" value="<?= (int) $entry['id'] ?>">
<div>
<label class="form-label" for="comment_message">Add comment</label>
<textarea id="comment_message" class="form-control" name="comment_message" rows="3" maxlength="500" placeholder="Share context, blockers, or coaching notes"></textarea>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-dark" type="submit">Post comment</button>
</div>
</form>
<div class="comment-stream">
<?php if ($entry['comments'] === []): ?>
<div class="empty-state compact-empty">
<strong>No comments yet.</strong>
<span>Comments are shared and visible alongside the approval trail.</span>
</div>
<?php else: ?>
<?php foreach (array_reverse($entry['comments']) as $comment): ?>
<article class="comment-card">
<div class="activity-topline">
<strong><?= e((string) $comment['actor_name']) ?></strong>
<span><?= e(okr_time_label((string) $comment['time'])) ?></span>
</div>
<div class="activity-text"><?= e((string) $comment['message']) ?></div>
<div class="activity-meta"><?= e((string) $comment['actor_role']) ?></div>
</article>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="surface-card mb-4">
<div class="section-kicker">Permissions</div>
<h2 class="section-title">Current access</h2>
<div class="meta-list">
<span><strong><?= e($profile['name']) ?></strong></span>
<span><?= e($profile['role']) ?><?= $profile['level'] !== $profile['role'] ? ' · ' . e($profile['level']) : '' ?></span>
<span><?= $canReview ? 'Can review this objective' : 'Cannot review this objective' ?></span>
<span><?= $canEditOwner ? 'Can update owner scores' : 'Cannot update owner scores' ?></span>
</div>
</div>
<div class="surface-card mb-4">
<div class="section-kicker">Recent activity</div>
<h2 class="section-title">Objective timeline</h2>
<div class="activity-list">
<?php foreach (array_reverse($entry['activity']) as $activity): ?>
<div class="activity-item static-item">
<div class="activity-topline">
<strong><?= e((string) $activity['actor_name']) ?></strong>
<span><?= e(okr_time_label((string) $activity['time'])) ?></span>
</div>
<div class="activity-text"><?= e((string) $activity['message']) ?></div>
<div class="activity-meta"><?= e((string) ($activity['actor_role'] ?? 'System')) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="surface-card mb-4">
<div class="section-kicker">Global notifications</div>
<h2 class="section-title">What everyone can see</h2>
<div class="activity-list" id="activityFeed" data-feed-url="feed.php">
<?php if ($notifications === []): ?>
<div class="empty-state compact-empty">
<strong>No notifications yet.</strong>
<span>Workspace activity will show up here automatically.</span>
</div>
<?php else: ?>
<?php foreach ($notifications as $notification): ?>
<a href="okr_detail.php?id=<?= (int) $notification['objective_id'] ?>" class="activity-item text-decoration-none">
<div class="activity-topline">
<strong><?= e((string) $notification['actor_name']) ?></strong>
<span><?= e(okr_time_label((string) $notification['time'])) ?></span>
</div>
<div class="activity-text"><?= e((string) $notification['message']) ?></div>
<div class="activity-meta"><?= e((string) $notification['objective_title']) ?></div>
</a>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<?php if ($canEditOwner && $entry['status'] === 'Draft'): ?>
<div class="surface-card">
<div class="section-kicker">Cleanup</div>
<h2 class="section-title">Delete draft</h2>
<p class="text-muted small mb-3">Delete is limited to draft objectives in this first iteration to keep the workflow safe.</p>
<form method="post" onsubmit="return confirm('Delete this draft objective?');">
<input type="hidden" name="csrf_token" value="<?= e(okr_csrf_token()) ?>">
<input type="hidden" name="action_type" value="delete_okr">
<input type="hidden" name="id" value="<?= (int) $entry['id'] ?>">
<button class="btn btn-outline-secondary w-100" type="submit">Delete draft</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
</section>
</main>
</div>
<?php if ($flash): ?>
<div class="app-flash" data-flash-type="<?= e((string) $flash['type']) ?>" data-flash-message="<?= e((string) $flash['message']) ?>"></div>
<?php endif; ?>
<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=<?= e($jsVersion) ?>" defer></script>
</body>
</html>