diff --git a/assets/css/custom.css b/assets/css/custom.css
index 789132e..785bb39 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -1,403 +1,467 @@
+:root {
+ --bg: #f1efe3;
+ --surface: #fcfbf6;
+ --surface-muted: #f5f3e9;
+ --border: #d9d5c8;
+ --border-strong: #bdb7a5;
+ --text: #111111;
+ --text-secondary: #5f5c54;
+ --mantis: #68bb59;
+ --lime: #32cd32;
+ --black: #000000;
+ --danger: #b54545;
+ --warning: #b98600;
+ --success: #3f8d3e;
+ --shadow: 0 8px 24px rgba(17, 17, 17, 0.06);
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.5rem;
+ --space-6: 2rem;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
body {
- background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
- background-size: 400% 400%;
- animation: gradient 15s ease infinite;
- color: #212529;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
- font-size: 14px;
margin: 0;
- min-height: 100vh;
-}
-
-.main-wrapper {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 100vh;
- width: 100%;
- padding: 20px;
- box-sizing: border-box;
- position: relative;
- z-index: 1;
-}
-
-@keyframes gradient {
- 0% {
- background-position: 0% 50%;
- }
- 50% {
- background-position: 100% 50%;
- }
- 100% {
- background-position: 0% 50%;
- }
-}
-
-.chat-container {
- width: 100%;
- max-width: 600px;
- background: rgba(255, 255, 255, 0.85);
- border: 1px solid rgba(255, 255, 255, 0.3);
- border-radius: 20px;
- display: flex;
- flex-direction: column;
- height: 85vh;
- box-shadow: 0 20px 40px rgba(0,0,0,0.2);
- backdrop-filter: blur(15px);
- -webkit-backdrop-filter: blur(15px);
- overflow: hidden;
-}
-
-.chat-header {
- padding: 1.5rem;
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
- background: rgba(255, 255, 255, 0.5);
- font-weight: 700;
- font-size: 1.1rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
-}
-
-/* Custom Scrollbar */
-::-webkit-scrollbar {
- width: 6px;
-}
-
-::-webkit-scrollbar-track {
- background: transparent;
-}
-
-::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 10px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.5);
-}
-
-.message {
- max-width: 85%;
- padding: 0.85rem 1.1rem;
- border-radius: 16px;
- line-height: 1.5;
- font-size: 0.95rem;
- box-shadow: 0 4px 15px rgba(0,0,0,0.05);
- animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-}
-
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(20px) scale(0.95); }
- to { opacity: 1; transform: translateY(0) scale(1); }
-}
-
-.message.visitor {
- align-self: flex-end;
- background: linear-gradient(135deg, #212529 0%, #343a40 100%);
- color: #fff;
- border-bottom-right-radius: 4px;
-}
-
-.message.bot {
- align-self: flex-start;
- background: #ffffff;
- color: #212529;
- border-bottom-left-radius: 4px;
-}
-
-.chat-input-area {
- padding: 1.25rem;
- background: rgba(255, 255, 255, 0.5);
- border-top: 1px solid rgba(0, 0, 0, 0.05);
-}
-
-.chat-input-area form {
- display: flex;
- gap: 0.75rem;
-}
-
-.chat-input-area input {
- flex: 1;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- padding: 0.75rem 1rem;
- outline: none;
- background: rgba(255, 255, 255, 0.9);
- transition: all 0.3s ease;
-}
-
-.chat-input-area input:focus {
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
-}
-
-.chat-input-area button {
- background: #212529;
- color: #fff;
- border: none;
- padding: 0.75rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- transition: all 0.3s ease;
-}
-
-.chat-input-area button:hover {
- background: #000;
- transform: translateY(-2px);
- box-shadow: 0 5px 15px rgba(0,0,0,0.2);
-}
-
-/* Background Animations */
-.bg-animations {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 0;
- overflow: hidden;
- pointer-events: none;
-}
-
-.blob {
- position: absolute;
- width: 500px;
- height: 500px;
- background: rgba(255, 255, 255, 0.2);
- border-radius: 50%;
- filter: blur(80px);
- animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
-}
-
-.blob-1 {
- top: -10%;
- left: -10%;
- background: rgba(238, 119, 82, 0.4);
-}
-
-.blob-2 {
- bottom: -10%;
- right: -10%;
- background: rgba(35, 166, 213, 0.4);
- animation-delay: -7s;
- width: 600px;
- height: 600px;
-}
-
-.blob-3 {
- top: 40%;
- left: 30%;
- background: rgba(231, 60, 126, 0.3);
- animation-delay: -14s;
- width: 450px;
- height: 450px;
-}
-
-@keyframes move {
- 0% { transform: translate(0, 0) rotate(0deg) scale(1); }
- 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
- 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
- 100% { transform: translate(0, 0) rotate(360deg) scale(1); }
-}
-
-.header-link {
+ background: var(--bg);
+ color: var(--text);
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
- color: #fff;
+ line-height: 1.5;
+}
+
+body.auth-shell,
+body.detail-shell,
+body.app-shell {
+ min-height: 100vh;
+}
+
+a {
+ color: inherit;
+}
+
+.brand-mark {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-weight: 700;
+ font-size: 1rem;
text-decoration: none;
- background: rgba(0, 0, 0, 0.2);
- padding: 0.5rem 1rem;
- border-radius: 8px;
- transition: all 0.3s ease;
+ color: var(--black);
}
-.header-link:hover {
- background: rgba(0, 0, 0, 0.4);
- text-decoration: none;
+.brand-mark::before {
+ content: "";
+ width: 12px;
+ height: 12px;
+ border-radius: 3px;
+ background: var(--mantis);
+ box-shadow: 14px 0 0 var(--black), 28px 0 0 var(--lime);
+ margin-right: 32px;
}
-/* Admin Styles */
-.admin-container {
- max-width: 900px;
- margin: 3rem auto;
- padding: 2.5rem;
- background: rgba(255, 255, 255, 0.85);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-radius: 24px;
- box-shadow: 0 20px 50px rgba(0,0,0,0.15);
- border: 1px solid rgba(255, 255, 255, 0.4);
- position: relative;
- z-index: 1;
-}
-
-.admin-container h1 {
- margin-top: 0;
- color: #212529;
- font-weight: 800;
-}
-
-.table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0 8px;
- margin-top: 1.5rem;
-}
-
-.table th {
- background: transparent;
- border: none;
- padding: 1rem;
- color: #6c757d;
- font-weight: 600;
+.eyebrow-tag,
+.tiny-label {
+ letter-spacing: 0.12em;
text-transform: uppercase;
- font-size: 0.75rem;
- letter-spacing: 1px;
+ font-size: 0.72rem;
+ color: var(--text-secondary);
}
-.table td {
- background: #fff;
- padding: 1rem;
- border: none;
+.display-title {
+ font-size: clamp(2rem, 5vw, 3.4rem);
+ line-height: 1.05;
+ letter-spacing: -0.04em;
+ max-width: 12ch;
}
-.table tr td:first-child { border-radius: 12px 0 0 12px; }
-.table tr td:last-child { border-radius: 0 12px 12px 0; }
-
-.form-group {
- margin-bottom: 1.25rem;
+.surface-card,
+.surface-muted,
+.metric-card,
+.list-row,
+.empty-state,
+.offcanvas,
+.sidebar,
+.topbar,
+.footer-bar {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ box-shadow: var(--shadow);
}
-.form-group label {
- display: block;
- margin-bottom: 0.5rem;
+.surface-card {
+ border-radius: var(--radius-lg);
+ padding: var(--space-5);
+}
+
+.surface-muted {
+ background: var(--surface-muted);
+ border: 1px solid var(--border);
+}
+
+.text-secondary,
+.form-text,
+.small {
+ color: var(--text-secondary) !important;
+}
+
+.auth-panel {
+ background: var(--surface-muted);
+ border-right: 1px solid var(--border);
+}
+
+.auth-form-column {
+ background: rgba(252, 251, 246, 0.72);
+}
+
+.auth-card {
+ max-width: 520px;
+}
+
+.hero-copy {
+ max-width: 560px;
+}
+
+.btn {
+ border-radius: 10px;
+ padding: 0.7rem 1rem;
font-weight: 600;
- font-size: 0.9rem;
+ box-shadow: none !important;
}
-.form-control {
- width: 100%;
- padding: 0.75rem 1rem;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- background: #fff;
- transition: all 0.3s ease;
- box-sizing: border-box;
+.btn-brand {
+ background: var(--black);
+ color: #ffffff;
+ border: 1px solid var(--black);
}
-.form-control:focus {
+.btn-brand:hover,
+.btn-brand:focus {
+ background: #1a1a1a;
+ color: #ffffff;
+}
+
+.btn-outline-secondary,
+.btn-outline-danger,
+.btn-notify {
+ border-color: var(--border-strong);
+ color: var(--text);
+ background: var(--surface);
+}
+
+.btn-outline-secondary:hover,
+.btn-notify:hover,
+.btn-notify:focus {
+ background: var(--surface-muted);
+ color: var(--text);
+ border-color: var(--border-strong);
+}
+
+.form-control,
+.form-select,
+textarea {
+ border-radius: 10px;
+ border: 1px solid var(--border-strong);
+ min-height: 44px;
+ background: #ffffff;
+ color: var(--text);
+}
+
+.form-control:focus,
+.form-select:focus,
+textarea:focus,
+.btn:focus-visible,
+.nav-link:focus-visible,
+.list-row:focus-visible {
+ border-color: var(--mantis);
+ box-shadow: 0 0 0 0.2rem rgba(104, 187, 89, 0.15) !important;
outline: none;
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
}
-.header-container {
+.app-layout {
+ display: flex;
+ min-height: 100vh;
+}
+
+.sidebar {
+ width: 280px;
+ padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ background: var(--surface-muted);
+ position: sticky;
+ top: 0;
+ height: 100vh;
+}
+
+.sidebar-nav .nav-link {
+ border-radius: 10px;
+ color: var(--text-secondary);
+ padding: 0.8rem 0.9rem;
+ border: 1px solid transparent;
+}
+
+.sidebar-nav .nav-link:hover,
+.sidebar-nav .nav-link.active {
+ background: var(--surface);
+ color: var(--text);
+ border-color: var(--border);
+}
+
+.main-panel {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.topbar,
+.footer-bar {
+ padding: 1rem 1.5rem;
+ background: rgba(252, 251, 246, 0.9);
+ position: sticky;
+ z-index: 10;
+}
+
+.topbar {
+ top: 0;
display: flex;
justify-content: space-between;
align-items: center;
-}
-
-.header-links {
- display: flex;
gap: 1rem;
}
-.admin-card {
- background: rgba(255, 255, 255, 0.6);
- padding: 2rem;
- border-radius: 20px;
- border: 1px solid rgba(255, 255, 255, 0.5);
- margin-bottom: 2.5rem;
- box-shadow: 0 10px 30px rgba(0,0,0,0.05);
+.footer-bar {
+ bottom: 0;
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ font-size: 0.82rem;
+ color: var(--text-secondary);
}
-.admin-card h3 {
- margin-top: 0;
- margin-bottom: 1.5rem;
+.topbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.search-wrap {
+ min-width: min(320px, 100%);
+}
+
+.content-area {
+ padding: 1.5rem;
+}
+
+.hero-panel {
+ display: flex;
+ justify-content: space-between;
+ align-items: end;
+ gap: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.hero-meta-grid,
+.stats-grid {
+ display: grid;
+ gap: 1rem;
+}
+
+.hero-meta-grid {
+ grid-template-columns: repeat(3, minmax(110px, 1fr));
+}
+
+.stats-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.metric-card {
+ border-radius: var(--radius-lg);
+ padding: 1.2rem;
+}
+
+.metric-value {
+ font-size: clamp(1.6rem, 4vw, 2rem);
font-weight: 700;
+ letter-spacing: -0.04em;
+ margin: 0.35rem 0;
}
-.btn-delete {
- background: #dc3545;
- color: white;
- border: none;
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- cursor: pointer;
-}
-
-.btn-add {
- background: #212529;
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 4px;
- cursor: pointer;
- margin-top: 1rem;
-}
-
-.btn-save {
- background: #0088cc;
- color: white;
- border: none;
- padding: 0.8rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- width: 100%;
- transition: all 0.3s ease;
-}
-
-.webhook-url {
- font-size: 0.85em;
- color: #555;
- margin-top: 0.5rem;
-}
-
-.history-table-container {
- overflow-x: auto;
- background: rgba(255, 255, 255, 0.4);
+.list-row {
+ border-radius: 14px;
padding: 1rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ transition: border-color 0.2s ease, transform 0.2s ease;
+}
+
+.list-row:hover {
+ transform: translateY(-1px);
+ border-color: var(--border-strong);
+}
+
+.thin-progress,
+.large-progress {
+ background: #ebe7d8;
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.thin-progress {
+ height: 8px;
+}
+
+.large-progress {
+ height: 12px;
+}
+
+.progress-bar.bg-success {
+ background: var(--mantis) !important;
+}
+
+.progress-bar.bg-dark {
+ background: var(--black) !important;
+}
+
+.score-preview {
border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.3);
+ min-height: 44px;
+ padding: 0.75rem 0.9rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
}
-.history-table {
- width: 100%;
+.badge {
+ border-radius: 999px;
+ padding: 0.45rem 0.7rem;
+ font-weight: 600;
+ font-size: 0.72rem;
+ text-transform: capitalize;
}
-.history-table-time {
- width: 15%;
- white-space: nowrap;
- font-size: 0.85em;
- color: #555;
+.badge-soft-success {
+ background: rgba(104, 187, 89, 0.16);
+ color: #245b24;
}
-.history-table-user {
- width: 35%;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 8px;
- padding: 8px;
+.badge-soft-warning {
+ background: rgba(185, 134, 0, 0.13);
+ color: #7b5a00;
}
-.history-table-ai {
- width: 50%;
- background: rgba(255, 255, 255, 0.5);
- border-radius: 8px;
- padding: 8px;
+.badge-soft-danger {
+ background: rgba(181, 69, 69, 0.12);
+ color: #8f2d2d;
}
-.no-messages {
- text-align: center;
- color: #777;
-}
\ No newline at end of file
+.badge-soft-neutral {
+ background: rgba(17, 17, 17, 0.08);
+ color: var(--text-secondary);
+}
+
+.notification-badge {
+ position: absolute;
+ top: -6px;
+ right: -8px;
+}
+
+.table {
+ --bs-table-bg: transparent;
+ --bs-table-border-color: var(--border);
+}
+
+.table thead th {
+ color: var(--text-secondary);
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.table tbody tr {
+ border-color: var(--border);
+}
+
+.toast-stack {
+ position: sticky;
+ top: 88px;
+ z-index: 9;
+}
+
+.alert {
+ border-radius: 12px;
+}
+
+.offcanvas {
+ background: var(--surface);
+}
+
+@media (max-width: 1199.98px) {
+ .stats-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ z-index: 30;
+ transform: translateX(-102%);
+ transition: transform 0.25s ease;
+ height: 100vh;
+ }
+
+ .sidebar.is-open {
+ transform: translateX(0);
+ }
+}
+
+@media (max-width: 767.98px) {
+ .display-title {
+ max-width: none;
+ }
+
+ .stats-grid,
+ .hero-meta-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .topbar,
+ .footer-bar,
+ .content-area,
+ .sidebar {
+ padding: 1rem;
+ }
+
+ .topbar {
+ align-items: flex-start;
+ }
+
+ .topbar-actions {
+ width: 100%;
+ }
+
+ .search-wrap {
+ min-width: 100%;
+ }
+
+ .hero-panel,
+ .list-row,
+ .footer-bar {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
diff --git a/assets/js/main.js b/assets/js/main.js
index d349598..ab9774f 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -1,39 +1,57 @@
document.addEventListener('DOMContentLoaded', () => {
- const chatForm = document.getElementById('chat-form');
- const chatInput = document.getElementById('chat-input');
- const chatMessages = document.getElementById('chat-messages');
+ const searchInput = document.getElementById('tableSearch');
+ const rows = Array.from(document.querySelectorAll('.js-search-row'));
- const appendMessage = (text, sender) => {
- const msgDiv = document.createElement('div');
- msgDiv.classList.add('message', sender);
- msgDiv.textContent = text;
- chatMessages.appendChild(msgDiv);
- chatMessages.scrollTop = chatMessages.scrollHeight;
+ if (searchInput && rows.length) {
+ searchInput.addEventListener('input', (event) => {
+ const query = event.target.value.trim().toLowerCase();
+ rows.forEach((row) => {
+ const text = row.textContent.toLowerCase();
+ row.style.display = text.includes(query) ? '' : 'none';
+ });
+ });
+ }
+
+ const syncScorePreview = (container) => {
+ const targetInput = container.querySelector('.js-score-target');
+ const currentInput = container.querySelector('.js-score-current');
+ const output = container.querySelector('.js-score-output');
+
+ if (!targetInput || !currentInput || !output) {
+ return;
+ }
+
+ const render = () => {
+ const target = parseFloat(targetInput.value || '0');
+ const current = parseFloat(currentInput.value || '0');
+ let score = 0;
+ if (target > 0) {
+ score = Math.max(0, Math.min(100, (current / target) * 100));
+ }
+ output.textContent = `${score.toFixed(1).replace('.0', '')}%`;
+ };
+
+ ['input', 'change'].forEach((eventName) => {
+ targetInput.addEventListener(eventName, render);
+ currentInput.addEventListener(eventName, render);
+ });
+ render();
};
- chatForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const message = chatInput.value.trim();
- if (!message) return;
+ document.querySelectorAll('#okrCreateForm, #okrReviewForm').forEach(syncScorePreview);
- appendMessage(message, 'visitor');
- chatInput.value = '';
+ const sidebar = document.getElementById('sidebarMenu');
+ const sidebarToggle = document.getElementById('sidebarToggle');
+ if (sidebar && sidebarToggle) {
+ sidebarToggle.addEventListener('click', () => {
+ sidebar.classList.toggle('is-open');
+ });
+ }
- try {
- const response = await fetch('api/chat.php', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ message })
- });
- const data = await response.json();
-
- // Artificial delay for realism
- setTimeout(() => {
- appendMessage(data.reply, 'bot');
- }, 500);
- } catch (error) {
- console.error('Error:', error);
- appendMessage("Sorry, something went wrong. Please try again.", 'bot');
- }
+ document.querySelectorAll('[data-auto-dismiss="true"]').forEach((alertEl) => {
+ window.setTimeout(() => {
+ alertEl.classList.add('fade');
+ alertEl.classList.remove('show');
+ }, 3200);
});
});
diff --git a/cookiejar.txt b/cookiejar.txt
new file mode 100644
index 0000000..c115dc2
--- /dev/null
+++ b/cookiejar.txt
@@ -0,0 +1,5 @@
+# Netscape HTTP Cookie File
+# https://curl.se/docs/http-cookies.html
+# This file was generated by libcurl! Edit at your own risk.
+
+127.0.0.1 FALSE / FALSE 0 PHPSESSID jc9efr4es6hc7c92tklakfbc5d
diff --git a/db/migrations/001_create_okr_items.sql b/db/migrations/001_create_okr_items.sql
new file mode 100644
index 0000000..04aac1b
--- /dev/null
+++ b/db/migrations/001_create_okr_items.sql
@@ -0,0 +1,25 @@
+-- Initial MVP slice for the OKR SaaS thin workflow.
+CREATE TABLE IF NOT EXISTS okr_items (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ organization_name VARCHAR(120) NOT NULL,
+ organization_slug VARCHAR(120) NOT NULL,
+ owner_name VARCHAR(120) NOT NULL,
+ owner_email VARCHAR(160) NOT NULL,
+ owner_role VARCHAR(40) NOT NULL,
+ department_name VARCHAR(120) NOT NULL,
+ period_name VARCHAR(120) NOT NULL,
+ objective_title VARCHAR(255) NOT NULL,
+ key_result_title VARCHAR(255) NOT NULL,
+ description TEXT NULL,
+ target_value DECIMAL(10,2) NOT NULL DEFAULT 100.00,
+ current_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ score_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
+ status VARCHAR(40) NOT NULL DEFAULT 'draft',
+ approval_state VARCHAR(40) NOT NULL DEFAULT 'pending_manager',
+ manager_comment TEXT NULL,
+ created_by_email VARCHAR(160) NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_scope (organization_slug, department_name, approval_state),
+ INDEX idx_owner (owner_email, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/index.php b/index.php
index 7205f3d..a15e4c6 100644
--- a/index.php
+++ b/index.php
@@ -1,150 +1,628 @@
= 100 ? 'completed' : 'active') : 'submitted';
+ $managerComment = $approvalState === 'approved' ? 'Auto-approved on submission by leadership role.' : null;
+
+ $stmt = db()->prepare(
+ 'INSERT INTO okr_items (
+ organization_name,
+ organization_slug,
+ owner_name,
+ owner_email,
+ owner_role,
+ department_name,
+ period_name,
+ objective_title,
+ key_result_title,
+ description,
+ target_value,
+ current_value,
+ score_percent,
+ status,
+ approval_state,
+ manager_comment,
+ created_by_email
+ ) VALUES (
+ :organization_name,
+ :organization_slug,
+ :owner_name,
+ :owner_email,
+ :owner_role,
+ :department_name,
+ :period_name,
+ :objective_title,
+ :key_result_title,
+ :description,
+ :target_value,
+ :current_value,
+ :score_percent,
+ :status,
+ :approval_state,
+ :manager_comment,
+ :created_by_email
+ )'
+ );
+ $stmt->execute([
+ ':organization_name' => $user['organization_name'],
+ ':organization_slug' => $user['organization_slug'],
+ ':owner_name' => $user['name'],
+ ':owner_email' => $user['email'],
+ ':owner_role' => $user['role'],
+ ':department_name' => $departmentName,
+ ':period_name' => $periodName,
+ ':objective_title' => $objectiveTitle,
+ ':key_result_title' => $keyResultTitle,
+ ':description' => $description !== '' ? $description : null,
+ ':target_value' => $targetValue,
+ ':current_value' => $currentValue,
+ ':score_percent' => $scorePercent,
+ ':status' => $status,
+ ':approval_state' => $approvalState,
+ ':manager_comment' => $managerComment,
+ ':created_by_email' => $user['email'],
+ ]);
+
+ okr_flash('success', 'Objective created and routed into the workflow.');
+ header('Location: index.php#my-okrs');
+ exit;
+ } catch (Throwable $exception) {
+ okr_flash('danger', $exception->getMessage());
+ header('Location: index.php#my-okrs');
+ exit;
+ }
+}
+
+$projectName = okr_app_name();
+$projectDescription = okr_meta_description();
+$projectImageUrl = env_value('PROJECT_IMAGE_URL');
+$flash = okr_pull_flash();
+$csrfToken = okr_csrf_token();
+$scopeParams = okr_scope_params($user);
+$scopeClause = okr_scope_clause();
+
+$summaryStmt = db()->prepare(
+ 'SELECT
+ COUNT(*) AS total_items,
+ SUM(approval_state = "pending_manager") AS pending_items,
+ SUM(approval_state = "approved") AS approved_items,
+ ROUND(COALESCE(AVG(score_percent), 0), 1) AS average_score,
+ SUM(status = "completed") AS completed_items
+ FROM okr_items
+ WHERE ' . $scopeClause
+);
+foreach ($scopeParams as $key => $value) {
+ $summaryStmt->bindValue($key, $value);
+}
+$summaryStmt->execute();
+$summary = $summaryStmt->fetch() ?: ['total_items' => 0, 'pending_items' => 0, 'approved_items' => 0, 'average_score' => 0, 'completed_items' => 0];
+
+$recentStmt = db()->prepare(
+ 'SELECT id, organization_name, owner_name, owner_role, department_name, objective_title, score_percent, approval_state, updated_at
+ FROM okr_items
+ WHERE ' . $scopeClause . '
+ ORDER BY updated_at DESC
+ LIMIT 6'
+);
+foreach ($scopeParams as $key => $value) {
+ $recentStmt->bindValue($key, $value);
+}
+$recentStmt->execute();
+$recentItems = $recentStmt->fetchAll();
+
+$listStmt = db()->prepare(
+ 'SELECT id, owner_name, owner_role, department_name, period_name, objective_title, key_result_title, score_percent, status, approval_state, updated_at
+ FROM okr_items
+ WHERE ' . $scopeClause . '
+ ORDER BY created_at DESC
+ LIMIT 24'
+);
+foreach ($scopeParams as $key => $value) {
+ $listStmt->bindValue($key, $value);
+}
+$listStmt->execute();
+$okrItems = $listStmt->fetchAll();
+
+$myStmt = db()->prepare(
+ 'SELECT id, objective_title, key_result_title, score_percent, approval_state, updated_at
+ FROM okr_items
+ WHERE ' . $scopeClause . ' AND owner_email = :owner_email
+ ORDER BY created_at DESC
+ LIMIT 6'
+);
+foreach ($scopeParams as $key => $value) {
+ $myStmt->bindValue($key, $value);
+}
+$myStmt->bindValue(':owner_email', $user['email']);
+$myStmt->execute();
+$myItems = $myStmt->fetchAll();
+
+$approvalInbox = [];
+if (okr_is_approver($user['role'])) {
+ $approvalStmt = db()->prepare(
+ 'SELECT id, owner_name, department_name, objective_title, key_result_title, score_percent, updated_at
+ FROM okr_items
+ WHERE ' . $scopeClause . ' AND approval_state = :approval_state
+ ORDER BY updated_at DESC
+ LIMIT 5'
+ );
+ foreach ($scopeParams as $key => $value) {
+ $approvalStmt->bindValue($key, $value);
+ }
+ $approvalStmt->bindValue(':approval_state', 'pending_manager');
+ $approvalStmt->execute();
+ $approvalInbox = $approvalStmt->fetchAll();
+}
+
+$departmentStmt = db()->prepare(
+ 'SELECT department_name, COUNT(*) AS item_count, ROUND(COALESCE(AVG(score_percent), 0), 1) AS department_score
+ FROM okr_items
+ WHERE ' . $scopeClause . '
+ GROUP BY department_name
+ ORDER BY item_count DESC, department_name ASC
+ LIMIT 4'
+);
+foreach ($scopeParams as $key => $value) {
+ $departmentStmt->bindValue($key, $value);
+}
+$departmentStmt->execute();
+$departmentRows = $departmentStmt->fetchAll();
+
+$pendingCount = okr_notification_count($user);
+$completionRate = ((int) ($summary['total_items'] ?? 0)) > 0 ? round(((int) ($summary['completed_items'] ?? 0) / (int) $summary['total_items']) * 100) : 0;
?>
-
-
- New Style
-
-
-
-
-
-
-
-
+
+
+ = e($projectName) ?> · Workspace
+
+
+
+
-
-
-
-
-
+
+
+
-
-
-
-
+
+
-
-
-
-
Analyzing your requirements and generating your website…
-
- Loading…
-
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
+
+
+
+
+
+
+
+
+
+
Operational strategy workspace
+
Dashboard
+
+
+
+
+
+
+
+ Notifications
+ 0): ?>
+ = e((string) $pendingCount) ?>
+
+
+
+
+ = e($user['name']) ?>
+
+
+ = e($user['organization_name']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
First MVP delivery
+
A working OKR workflow for one tenant-aware organization at a time.
+
Create a personal objective, view organization-wide progress, and move items through manager review with auto-scored key results.
+
+
+
+
+
+
+
+ = e($flash['message']) ?>
+
+
+
+
+
+
+ Total OKRs
+ = e((string) ($summary['total_items'] ?? 0)) ?>
+ Scoped to = okr_is_super_admin() ? 'all organizations' : 'your organization' ?>
+
+
+ Pending approvals
+ = e((string) ($summary['pending_items'] ?? 0)) ?>
+ Queue for line-manager review
+
+
+ Approved items
+ = e((string) ($summary['approved_items'] ?? 0)) ?>
+ Includes leadership auto-approvals
+
+
+ Average score
+ = e((string) ($summary['average_score'] ?? 0)) ?>%
+ Calculated from key result progress
+
+
+
+
+
+
+
+
+
Corporate OKRs
+
Recent strategic objectives
+
A compact hierarchical view of the latest objectives and key results in scope.
+
+
Use the search field in the header to filter all table rows.
+
+
+
+
No OKRs yet
+
Create your first objective in the My OKRs section to populate the dashboard.
+
+
+
+
+
+
+
+
+
Department OKRs
+
Distribution by department
+
+
Department insights appear after your team creates records.
+
+
+
+
+
+ = e($departmentRow['department_name']) ?>
+ = e((string) $departmentRow['item_count']) ?> OKRs
+
+
+
Average score = e((string) $departmentRow['department_score']) ?>%
+
+
+
+
+
+
Staff OKRs
+
Workflow completion
+
+
= e((string) $completionRate) ?>% of in-scope objectives are completed.
+
+
+
+
+
+
+
+
+
+
My OKRs
+
Create a new objective
+
This thin slice covers create → confirmation → list → detail → approval.
+
+
All writes use PDO prepared statements.
+
+
+
+
+
+
+
Approval inbox
+
Items waiting for review
+
+
Only Manager, Director, CEO, Admin, and Super Admin roles can approve or reject submitted OKRs in this first release.
+
+
+
Inbox is clear
+
Pending approvals will appear here as staff submit new OKRs.
+
+
+
+
+
+
+
+
= e($pending['objective_title']) ?>
+
= e($pending['owner_name']) ?> · = e($pending['department_name']) ?>
+
+
+
= e((string) $pending['score_percent']) ?>%
+
Current score
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Shared list
+
In-scope OKR records
+
Each record opens a detail page for approvals, comments, and score updates.
+
+
Showing up to 24 most recent records.
+
+
+
+
Your workspace is ready for the first OKR
+
Create one above to activate the dashboard, approval inbox, and analytics cards.
+
+
+
+
+
+
+ Owner
+ Department
+ Objective
+ Score
+ Status
+ Action
+
+
+
+
+
+
+ = e($item['owner_name']) ?>
+ = e($item['owner_role']) ?>
+
+
+ = e($item['department_name']) ?>
+ = e($item['period_name']) ?>
+
+
+ = e($item['objective_title']) ?>
+ = e($item['key_result_title']) ?>
+
+
+ = e((string) $item['score_percent']) ?>%
+ Updated = e(date('M j', strtotime((string) $item['updated_at']))) ?>
+
+
+ = e($item['approval_state']) ?>
+ = e($item['status']) ?>
+
+
+ Open
+
+
+
+
+
+
+
+
+
+
+
+
+
Personal queue
+
Your latest submissions
+
Quick access to your own items inside the current organization scope.
+
+
Create another
+
+
+
+
+
No personal OKRs created yet in this workspace.
+
+
+
+
+
+
+
+
+
+
+
-
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
-
+
+
+
+
+
+
+
Approval workload
+
= e((string) $pendingCount) ?> item(s) are currently waiting for a line manager or leadership decision.
+
+
This initial delivery uses lightweight refreshes and contextual alerts. Real-time comment streams and richer notifications can be layered onto the same workflow next.
+
+
+
+
+
diff --git a/login.php b/login.php
new file mode 100644
index 0000000..10d86e6
--- /dev/null
+++ b/login.php
@@ -0,0 +1,176 @@
+ $organizationName,
+ 'organization_slug' => $organizationSlug,
+ 'name' => $fullName,
+ 'email' => $email,
+ 'role' => $role,
+ ];
+
+ okr_flash('success', 'Welcome back. Your workspace is ready.');
+ header('Location: index.php');
+ exit;
+ }
+ } catch (Throwable $exception) {
+ $errors[] = $exception->getMessage();
+ }
+}
+
+$projectName = okr_app_name();
+$projectDescription = okr_meta_description();
+$projectImageUrl = env_value('PROJECT_IMAGE_URL');
+$csrfToken = okr_csrf_token();
+?>
+
+
+
+
+
+
= e($projectName) ?> · Secure sign in
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Aligned OKR
+
Multi-tenant SaaS
+
+
+
Strategic execution platform
+
Run one tenant-aware OKR workflow from sign-in to approval.
+
This initial release gives each organization an isolated workspace with a clean dashboard, scoped OKR records, manager approvals, and lightweight analytics.
+
+
+
+
+
Included now
+
My OKRs workflow
+
Create objectives, track key results, and route them into approval with automatic score calculation.
+
+
+
+
+
Ready for managers
+
Approval inbox
+
Managers, directors, CEOs, and super admins can review progress, leave feedback, and approve or reject.
+
+
+
+
+
Version 0.1 · Designed for secure internal planning and execution.
+
+
+
+
+
Secure workspace access
+
Sign in to your organization
+
Use a work email and role to enter an isolated tenant workspace.
+
+
+
+
+
Please fix the following:
+
+
+
+
+
+
+
+ Organization name
+
+
+
+
Organization key
+
+
Used to isolate all OKR records for this organization.
+
+
+ Full name
+
+
+
+ Work email
+
+
+
+ Role
+
+
+ >= e($roleOption) ?>
+
+
+
+
+
Enter workspace
+
Roles included: Admin, CEO, Director, Manager, Team, Staff, plus Super Admin for SaaS oversight.
+
+
+
+
+
+
+
+
+
+
diff --git a/logout.php b/logout.php
new file mode 100644
index 0000000..037218f
--- /dev/null
+++ b/logout.php
@@ -0,0 +1,13 @@
+ $type,
+ 'message' => $message,
+ ];
+}
+
+function okr_pull_flash(): ?array
+{
+ $flash = $_SESSION['okr_flash'] ?? null;
+ unset($_SESSION['okr_flash']);
+
+ return is_array($flash) ? $flash : null;
+}
+
+function okr_csrf_token(): string
+{
+ if (empty($_SESSION['okr_csrf'])) {
+ $_SESSION['okr_csrf'] = bin2hex(random_bytes(16));
+ }
+
+ return (string) $_SESSION['okr_csrf'];
+}
+
+function okr_verify_csrf(): void
+{
+ $sessionToken = $_SESSION['okr_csrf'] ?? '';
+ $postedToken = $_POST['csrf_token'] ?? '';
+
+ if (!is_string($postedToken) || !hash_equals((string) $sessionToken, $postedToken)) {
+ throw new RuntimeException('Security validation failed. Please refresh and try again.');
+ }
+}
+
+function okr_scope_clause(string $alias = ''): string
+{
+ $prefix = $alias !== '' ? $alias . '.' : '';
+ return okr_is_super_admin() ? '1=1' : $prefix . 'organization_slug = :organization_slug';
+}
+
+function okr_scope_params(array $user): array
+{
+ return okr_is_super_admin() ? [] : [':organization_slug' => $user['organization_slug']];
+}
+
+function okr_calculate_score(float $currentValue, float $targetValue): float
+{
+ if ($targetValue <= 0) {
+ return 0.0;
+ }
+
+ $score = ($currentValue / $targetValue) * 100;
+ return round(max(0, min(100, $score)), 1);
+}
+
+function okr_badge_class(string $state): string
+{
+ return match ($state) {
+ 'approved', 'completed', 'active' => 'badge-soft-success',
+ 'pending_manager', 'submitted' => 'badge-soft-warning',
+ 'rejected', 'needs_revision' => 'badge-soft-danger',
+ default => 'badge-soft-neutral',
+ };
+}
+
+function okr_notification_count(array $user): int
+{
+ if (!okr_is_approver($user['role'])) {
+ return 0;
+ }
+
+ $params = okr_scope_params($user);
+ $stmt = db()->prepare('SELECT COUNT(*) FROM okr_items WHERE ' . okr_scope_clause() . ' AND approval_state = :approval_state');
+ foreach ($params as $key => $value) {
+ $stmt->bindValue($key, $value);
+ }
+ $stmt->bindValue(':approval_state', 'pending_manager');
+ $stmt->execute();
+
+ return (int) $stmt->fetchColumn();
+}
+
+function okr_ensure_schema(): void
+{
+ db()->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS okr_items (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ organization_name VARCHAR(120) NOT NULL,
+ organization_slug VARCHAR(120) NOT NULL,
+ owner_name VARCHAR(120) NOT NULL,
+ owner_email VARCHAR(160) NOT NULL,
+ owner_role VARCHAR(40) NOT NULL,
+ department_name VARCHAR(120) NOT NULL,
+ period_name VARCHAR(120) NOT NULL,
+ objective_title VARCHAR(255) NOT NULL,
+ key_result_title VARCHAR(255) NOT NULL,
+ description TEXT NULL,
+ target_value DECIMAL(10,2) NOT NULL DEFAULT 100.00,
+ current_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ score_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
+ status VARCHAR(40) NOT NULL DEFAULT 'draft',
+ approval_state VARCHAR(40) NOT NULL DEFAULT 'pending_manager',
+ manager_comment TEXT NULL,
+ created_by_email VARCHAR(160) NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_scope (organization_slug, department_name, approval_state),
+ INDEX idx_owner (owner_email, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+SQL);
+}
diff --git a/okr_detail.php b/okr_detail.php
new file mode 100644
index 0000000..b659001
--- /dev/null
+++ b/okr_detail.php
@@ -0,0 +1,249 @@
+= 100 ? 'completed' : 'active';
+ if ($managerComment === '') {
+ $managerComment = 'Approved and scored by ' . $user['role'] . '.';
+ }
+ } elseif ($decision === 'reject') {
+ $approvalState = 'rejected';
+ $status = 'needs_revision';
+ if ($managerComment === '') {
+ $managerComment = 'Rejected with feedback from ' . $user['role'] . '.';
+ }
+ } elseif ($managerComment === '') {
+ $managerComment = 'Progress updated by ' . $user['role'] . '.';
+ }
+
+ $sql = 'UPDATE okr_items SET current_value = :current_value, target_value = :target_value, score_percent = :score_percent, approval_state = :approval_state, status = :status, manager_comment = :manager_comment WHERE id = :id AND ' . $scopeClause;
+ $stmt = db()->prepare($sql);
+ $stmt->bindValue(':current_value', $currentValue);
+ $stmt->bindValue(':target_value', $targetValue);
+ $stmt->bindValue(':score_percent', $scorePercent);
+ $stmt->bindValue(':approval_state', $approvalState);
+ $stmt->bindValue(':status', $status);
+ $stmt->bindValue(':manager_comment', $managerComment);
+ $stmt->bindValue(':id', $id, PDO::PARAM_INT);
+ foreach ($scopeParams as $key => $value) {
+ $stmt->bindValue($key, $value);
+ }
+ $stmt->execute();
+
+ okr_flash('success', 'OKR updated successfully.');
+ header('Location: okr_detail.php?id=' . $id);
+ exit;
+ } catch (Throwable $exception) {
+ okr_flash('danger', $exception->getMessage());
+ header('Location: okr_detail.php?id=' . $id);
+ exit;
+ }
+}
+
+$sql = 'SELECT * FROM okr_items WHERE id = :id AND ' . $scopeClause . ' LIMIT 1';
+$stmt = db()->prepare($sql);
+$stmt->bindValue(':id', $id, PDO::PARAM_INT);
+foreach ($scopeParams as $key => $value) {
+ $stmt->bindValue($key, $value);
+}
+$stmt->execute();
+$item = $stmt->fetch();
+
+if (!$item) {
+ okr_flash('danger', 'That OKR could not be found in your current scope.');
+ header('Location: index.php');
+ exit;
+}
+
+$projectName = okr_app_name();
+$projectDescription = okr_meta_description();
+$projectImageUrl = env_value('PROJECT_IMAGE_URL');
+$flash = okr_pull_flash();
+$csrfToken = okr_csrf_token();
+?>
+
+
+
+
+
+
= e($projectName) ?> · OKR detail
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
OKR detail
+
= e($item['objective_title']) ?>
+
= e($item['organization_name']) ?> · = e($item['department_name']) ?> · = e($item['period_name']) ?>
+
+
+
+
+
+
+ = e($flash['message']) ?>
+
+
+
+
+
+
+
+
+
Key result
+
= e($item['key_result_title']) ?>
+
Owner: = e($item['owner_name']) ?> · = e($item['owner_role']) ?> · = e($item['owner_email']) ?>
+
+
= e($item['approval_state']) ?>
+
+
+
+
+
Current score
+
= e((string) $item['score_percent']) ?>%
+
+
+
+
+
Current value
+
= e((string) $item['current_value']) ?>
+
+
+
+
+
Target value
+
= e((string) $item['target_value']) ?>
+
+
+
+
+
+ Progress to target
+ = e((string) $item['score_percent']) ?>%
+
+
+
+
+
Objective notes
+
+ = nl2br(e($item['description'] ?: 'No additional notes supplied.')) ?>
+
+
+
+
Latest reviewer comment
+
+ = nl2br(e($item['manager_comment'] ?: 'No comments yet.')) ?>
+
+
+
+
+
+
+ Approval workflow
+ Review and score
+
+
+
+
+ Target value
+ >
+
+
+ Current value
+ >
+
+
+ Comment
+
+
+
+
Projected score
+
+ = e((string) $item['score_percent']) ?>%
+ Recomputed live in the browser
+
+
+
+
+ Approve and score
+ Save progress only
+ Reject with feedback
+
+
+ You can view this record, but only leadership roles can change approval status in this first release.
+
+
+
+
+ Audit snapshot
+
+ Created: = e(date('M j, Y H:i', strtotime((string) $item['created_at']))) ?> UTC
+ Updated: = e(date('M j, Y H:i', strtotime((string) $item['updated_at']))) ?> UTC
+ Status: = e((string) $item['status']) ?>
+ Approval: = e((string) $item['approval_state']) ?>
+
+
+
+
+
+
+
+
+