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