Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
765d998fa1 |
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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, '<')
|
||||||
msgDiv.textContent = text;
|
.replace(/>/g, '>')
|
||||||
chatMessages.appendChild(msgDiv);
|
.replace(/"/g, '"')
|
||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
.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) => {
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
24
db/migrations/20260401_create_okr_entries.sql
Normal file
24
db/migrations/20260401_create_okr_entries.sql
Normal 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
30
feed.php
Normal 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
664
includes/okr_app.php
Normal 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';
|
||||||
|
}
|
||||||
703
index.php
703
index.php
@ -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; ?>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<meta name="theme-color" content="#68BB59" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<style>
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
:root {
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
--bg-color-start: #6a11cb;
|
<link rel="stylesheet" href="assets/css/custom.css?v=<?= e($cssVersion) ?>">
|
||||||
--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>
|
<div>
|
||||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
<strong><?= e($projectName) ?></strong>
|
||||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
<small>OKR Workspace</small>
|
||||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
</div>
|
||||||
|
</a>
|
||||||
|
<p class="sidebar-copy">Simple, seamless objective planning with one-click submission, approval, scoring, and transparent updates.</p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<footer class="footer-bar">
|
||||||
|
<span>Version MVP-0.1 · OKR workflow slice</span>
|
||||||
|
<span>© <?= date('Y') ?> <?= e($projectName) ?></span>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
<footer>
|
|
||||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
<?php if ($flash): ?>
|
||||||
</footer>
|
<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
374
okr_detail.php
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user