Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c04a6c2d66 |
@ -1,403 +1,467 @@
|
||||
:root {
|
||||
--bg: #f1efe3;
|
||||
--surface: #fcfbf6;
|
||||
--surface-muted: #f5f3e9;
|
||||
--border: #d9d5c8;
|
||||
--border-strong: #bdb7a5;
|
||||
--text: #111111;
|
||||
--text-secondary: #5f5c54;
|
||||
--mantis: #68bb59;
|
||||
--lime: #32cd32;
|
||||
--black: #000000;
|
||||
--danger: #b54545;
|
||||
--warning: #b98600;
|
||||
--success: #3f8d3e;
|
||||
--shadow: 0 8px 24px rgba(17, 17, 17, 0.06);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2rem;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient 15s ease infinite;
|
||||
color: #212529;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 85vh;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-radius: 16px;
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.message.visitor {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.bot {
|
||||
align-self: flex-start;
|
||||
background: #ffffff;
|
||||
color: #212529;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chat-input-area form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 1rem;
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-input-area input:focus {
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
|
||||
}
|
||||
|
||||
.chat-input-area button {
|
||||
background: #212529;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-input-area button:hover {
|
||||
background: #000;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Background Animations */
|
||||
.bg-animations {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
background: rgba(238, 119, 82, 0.4);
|
||||
}
|
||||
|
||||
.blob-2 {
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
background: rgba(35, 166, 213, 0.4);
|
||||
animation-delay: -7s;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.blob-3 {
|
||||
top: 40%;
|
||||
left: 30%;
|
||||
background: rgba(231, 60, 126, 0.3);
|
||||
animation-delay: -14s;
|
||||
width: 450px;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
||||
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
|
||||
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
|
||||
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
.header-link {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body.auth-shell,
|
||||
body.detail-shell,
|
||||
body.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.header-link:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
text-decoration: none;
|
||||
.brand-mark::before {
|
||||
content: "";
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
background: var(--mantis);
|
||||
box-shadow: 14px 0 0 var(--black), 28px 0 0 var(--lime);
|
||||
margin-right: 32px;
|
||||
}
|
||||
|
||||
/* Admin Styles */
|
||||
.admin-container {
|
||||
max-width: 900px;
|
||||
margin: 3rem auto;
|
||||
padding: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.admin-container h1 {
|
||||
margin-top: 0;
|
||||
color: #212529;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 1rem;
|
||||
color: #6c757d;
|
||||
font-weight: 600;
|
||||
.eyebrow-tag,
|
||||
.tiny-label {
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.table td {
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
.display-title {
|
||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.table tr td:first-child { border-radius: 12px 0 0 12px; }
|
||||
.table tr td:last-child { border-radius: 0 12px 12px 0; }
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
.surface-card,
|
||||
.surface-muted,
|
||||
.metric-card,
|
||||
.list-row,
|
||||
.empty-state,
|
||||
.offcanvas,
|
||||
.sidebar,
|
||||
.topbar,
|
||||
.footer-bar {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
.surface-card {
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.surface-muted {
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.text-secondary,
|
||||
.form-text,
|
||||
.small {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
background: var(--surface-muted);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.auth-form-column {
|
||||
background: rgba(252, 251, 246, 0.72);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
.btn-brand {
|
||||
background: var(--black);
|
||||
color: #ffffff;
|
||||
border: 1px solid var(--black);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
.btn-brand:hover,
|
||||
.btn-brand:focus {
|
||||
background: #1a1a1a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-outline-secondary,
|
||||
.btn-outline-danger,
|
||||
.btn-notify {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover,
|
||||
.btn-notify:hover,
|
||||
.btn-notify:focus {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select,
|
||||
textarea {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
min-height: 44px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus,
|
||||
textarea:focus,
|
||||
.btn:focus-visible,
|
||||
.nav-link:focus-visible,
|
||||
.list-row:focus-visible {
|
||||
border-color: var(--mantis);
|
||||
box-shadow: 0 0 0 0.2rem rgba(104, 187, 89, 0.15) !important;
|
||||
outline: none;
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
background: var(--surface-muted);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link {
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.8rem 0.9rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link:hover,
|
||||
.sidebar-nav .nav-link.active {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.footer-bar {
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(252, 251, 246, 0.9);
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 2.5rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
||||
.footer-bar {
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
min-width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-meta-grid,
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero-meta-grid {
|
||||
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: clamp(1.6rem, 4vw, 2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: #212529;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #0088cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.webhook-url {
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.history-table-container {
|
||||
overflow-x: auto;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
.list-row {
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.list-row:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.thin-progress,
|
||||
.large-progress {
|
||||
background: #ebe7d8;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thin-progress {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.large-progress {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.progress-bar.bg-success {
|
||||
background: var(--mantis) !important;
|
||||
}
|
||||
|
||||
.progress-bar.bg-dark {
|
||||
background: var(--black) !important;
|
||||
}
|
||||
|
||||
.score-preview {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
min-height: 44px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
.badge {
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.history-table-time {
|
||||
width: 15%;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
.badge-soft-success {
|
||||
background: rgba(104, 187, 89, 0.16);
|
||||
color: #245b24;
|
||||
}
|
||||
|
||||
.history-table-user {
|
||||
width: 35%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.badge-soft-warning {
|
||||
background: rgba(185, 134, 0, 0.13);
|
||||
color: #7b5a00;
|
||||
}
|
||||
|
||||
.history-table-ai {
|
||||
width: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.badge-soft-danger {
|
||||
background: rgba(181, 69, 69, 0.12);
|
||||
color: #8f2d2d;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
text-align: center;
|
||||
color: #777;
|
||||
.badge-soft-neutral {
|
||||
background: rgba(17, 17, 17, 0.08);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.table {
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-border-color: var(--border);
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.offcanvas {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
@media (max-width: 1199.98px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 30;
|
||||
transform: translateX(-102%);
|
||||
transition: transform 0.25s ease;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.display-title {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.stats-grid,
|
||||
.hero-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.footer-bar,
|
||||
.content-area,
|
||||
.sidebar {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.list-row,
|
||||
.footer-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@ -1,39 +1,57 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
const searchInput = document.getElementById('tableSearch');
|
||||
const rows = Array.from(document.querySelectorAll('.js-search-row'));
|
||||
|
||||
const appendMessage = (text, sender) => {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('message', sender);
|
||||
msgDiv.textContent = text;
|
||||
chatMessages.appendChild(msgDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
if (searchInput && rows.length) {
|
||||
searchInput.addEventListener('input', (event) => {
|
||||
const query = event.target.value.trim().toLowerCase();
|
||||
rows.forEach((row) => {
|
||||
const text = row.textContent.toLowerCase();
|
||||
row.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const syncScorePreview = (container) => {
|
||||
const targetInput = container.querySelector('.js-score-target');
|
||||
const currentInput = container.querySelector('.js-score-current');
|
||||
const output = container.querySelector('.js-score-output');
|
||||
|
||||
if (!targetInput || !currentInput || !output) {
|
||||
return;
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
const target = parseFloat(targetInput.value || '0');
|
||||
const current = parseFloat(currentInput.value || '0');
|
||||
let score = 0;
|
||||
if (target > 0) {
|
||||
score = Math.max(0, Math.min(100, (current / target) * 100));
|
||||
}
|
||||
output.textContent = `${score.toFixed(1).replace('.0', '')}%`;
|
||||
};
|
||||
|
||||
['input', 'change'].forEach((eventName) => {
|
||||
targetInput.addEventListener(eventName, render);
|
||||
currentInput.addEventListener(eventName, render);
|
||||
});
|
||||
render();
|
||||
};
|
||||
|
||||
chatForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const message = chatInput.value.trim();
|
||||
if (!message) return;
|
||||
document.querySelectorAll('#okrCreateForm, #okrReviewForm').forEach(syncScorePreview);
|
||||
|
||||
appendMessage(message, 'visitor');
|
||||
chatInput.value = '';
|
||||
const sidebar = document.getElementById('sidebarMenu');
|
||||
const sidebarToggle = document.getElementById('sidebarToggle');
|
||||
if (sidebar && sidebarToggle) {
|
||||
sidebarToggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('is-open');
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('api/chat.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Artificial delay for realism
|
||||
setTimeout(() => {
|
||||
appendMessage(data.reply, 'bot');
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
||||
}
|
||||
document.querySelectorAll('[data-auto-dismiss="true"]').forEach((alertEl) => {
|
||||
window.setTimeout(() => {
|
||||
alertEl.classList.add('fade');
|
||||
alertEl.classList.remove('show');
|
||||
}, 3200);
|
||||
});
|
||||
});
|
||||
|
||||
5
cookiejar.txt
Normal file
5
cookiejar.txt
Normal file
@ -0,0 +1,5 @@
|
||||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
127.0.0.1 FALSE / FALSE 0 PHPSESSID jc9efr4es6hc7c92tklakfbc5d
|
||||
25
db/migrations/001_create_okr_items.sql
Normal file
25
db/migrations/001_create_okr_items.sql
Normal file
@ -0,0 +1,25 @@
|
||||
-- Initial MVP slice for the OKR SaaS thin workflow.
|
||||
CREATE TABLE IF NOT EXISTS okr_items (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
organization_name VARCHAR(120) NOT NULL,
|
||||
organization_slug VARCHAR(120) NOT NULL,
|
||||
owner_name VARCHAR(120) NOT NULL,
|
||||
owner_email VARCHAR(160) NOT NULL,
|
||||
owner_role VARCHAR(40) NOT NULL,
|
||||
department_name VARCHAR(120) NOT NULL,
|
||||
period_name VARCHAR(120) NOT NULL,
|
||||
objective_title VARCHAR(255) NOT NULL,
|
||||
key_result_title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
target_value DECIMAL(10,2) NOT NULL DEFAULT 100.00,
|
||||
current_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
score_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'draft',
|
||||
approval_state VARCHAR(40) NOT NULL DEFAULT 'pending_manager',
|
||||
manager_comment TEXT NULL,
|
||||
created_by_email VARCHAR(160) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_scope (organization_slug, department_name, approval_state),
|
||||
INDEX idx_owner (owner_email, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
752
index.php
752
index.php
@ -1,150 +1,628 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
require_once __DIR__ . '/okr_bootstrap.php';
|
||||
okr_ensure_schema();
|
||||
|
||||
if (empty($_SESSION['okr_user'])) {
|
||||
require __DIR__ . '/login.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = okr_current_user();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'create_okr') {
|
||||
try {
|
||||
okr_verify_csrf();
|
||||
|
||||
$departmentName = trim((string) ($_POST['department_name'] ?? ''));
|
||||
$periodName = trim((string) ($_POST['period_name'] ?? ''));
|
||||
$objectiveTitle = trim((string) ($_POST['objective_title'] ?? ''));
|
||||
$keyResultTitle = trim((string) ($_POST['key_result_title'] ?? ''));
|
||||
$description = trim((string) ($_POST['description'] ?? ''));
|
||||
$targetValue = (float) ($_POST['target_value'] ?? 0);
|
||||
$currentValue = (float) ($_POST['current_value'] ?? 0);
|
||||
|
||||
$errors = [];
|
||||
if ($departmentName === '') {
|
||||
$errors[] = 'Department is required.';
|
||||
}
|
||||
if ($periodName === '') {
|
||||
$errors[] = 'OKR period is required.';
|
||||
}
|
||||
if ($objectiveTitle === '' || strlen($objectiveTitle) < 8) {
|
||||
$errors[] = 'Objective title must be at least 8 characters.';
|
||||
}
|
||||
if ($keyResultTitle === '' || strlen($keyResultTitle) < 8) {
|
||||
$errors[] = 'Key result title must be at least 8 characters.';
|
||||
}
|
||||
if ($targetValue <= 0) {
|
||||
$errors[] = 'Target value must be greater than 0.';
|
||||
}
|
||||
if ($currentValue < 0) {
|
||||
$errors[] = 'Current value cannot be negative.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException(implode(' ', $errors));
|
||||
}
|
||||
|
||||
$scorePercent = okr_calculate_score($currentValue, $targetValue);
|
||||
$approvalState = okr_is_approver($user['role']) ? 'approved' : 'pending_manager';
|
||||
$status = $approvalState === 'approved' ? ($scorePercent >= 100 ? 'completed' : 'active') : 'submitted';
|
||||
$managerComment = $approvalState === 'approved' ? 'Auto-approved on submission by leadership role.' : null;
|
||||
|
||||
$stmt = db()->prepare(
|
||||
'INSERT INTO okr_items (
|
||||
organization_name,
|
||||
organization_slug,
|
||||
owner_name,
|
||||
owner_email,
|
||||
owner_role,
|
||||
department_name,
|
||||
period_name,
|
||||
objective_title,
|
||||
key_result_title,
|
||||
description,
|
||||
target_value,
|
||||
current_value,
|
||||
score_percent,
|
||||
status,
|
||||
approval_state,
|
||||
manager_comment,
|
||||
created_by_email
|
||||
) VALUES (
|
||||
:organization_name,
|
||||
:organization_slug,
|
||||
:owner_name,
|
||||
:owner_email,
|
||||
:owner_role,
|
||||
:department_name,
|
||||
:period_name,
|
||||
:objective_title,
|
||||
:key_result_title,
|
||||
:description,
|
||||
:target_value,
|
||||
:current_value,
|
||||
:score_percent,
|
||||
:status,
|
||||
:approval_state,
|
||||
:manager_comment,
|
||||
:created_by_email
|
||||
)'
|
||||
);
|
||||
$stmt->execute([
|
||||
':organization_name' => $user['organization_name'],
|
||||
':organization_slug' => $user['organization_slug'],
|
||||
':owner_name' => $user['name'],
|
||||
':owner_email' => $user['email'],
|
||||
':owner_role' => $user['role'],
|
||||
':department_name' => $departmentName,
|
||||
':period_name' => $periodName,
|
||||
':objective_title' => $objectiveTitle,
|
||||
':key_result_title' => $keyResultTitle,
|
||||
':description' => $description !== '' ? $description : null,
|
||||
':target_value' => $targetValue,
|
||||
':current_value' => $currentValue,
|
||||
':score_percent' => $scorePercent,
|
||||
':status' => $status,
|
||||
':approval_state' => $approvalState,
|
||||
':manager_comment' => $managerComment,
|
||||
':created_by_email' => $user['email'],
|
||||
]);
|
||||
|
||||
okr_flash('success', 'Objective created and routed into the workflow.');
|
||||
header('Location: index.php#my-okrs');
|
||||
exit;
|
||||
} catch (Throwable $exception) {
|
||||
okr_flash('danger', $exception->getMessage());
|
||||
header('Location: index.php#my-okrs');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$projectName = okr_app_name();
|
||||
$projectDescription = okr_meta_description();
|
||||
$projectImageUrl = env_value('PROJECT_IMAGE_URL');
|
||||
$flash = okr_pull_flash();
|
||||
$csrfToken = okr_csrf_token();
|
||||
$scopeParams = okr_scope_params($user);
|
||||
$scopeClause = okr_scope_clause();
|
||||
|
||||
$summaryStmt = db()->prepare(
|
||||
'SELECT
|
||||
COUNT(*) AS total_items,
|
||||
SUM(approval_state = "pending_manager") AS pending_items,
|
||||
SUM(approval_state = "approved") AS approved_items,
|
||||
ROUND(COALESCE(AVG(score_percent), 0), 1) AS average_score,
|
||||
SUM(status = "completed") AS completed_items
|
||||
FROM okr_items
|
||||
WHERE ' . $scopeClause
|
||||
);
|
||||
foreach ($scopeParams as $key => $value) {
|
||||
$summaryStmt->bindValue($key, $value);
|
||||
}
|
||||
$summaryStmt->execute();
|
||||
$summary = $summaryStmt->fetch() ?: ['total_items' => 0, 'pending_items' => 0, 'approved_items' => 0, 'average_score' => 0, 'completed_items' => 0];
|
||||
|
||||
$recentStmt = db()->prepare(
|
||||
'SELECT id, organization_name, owner_name, owner_role, department_name, objective_title, score_percent, approval_state, updated_at
|
||||
FROM okr_items
|
||||
WHERE ' . $scopeClause . '
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 6'
|
||||
);
|
||||
foreach ($scopeParams as $key => $value) {
|
||||
$recentStmt->bindValue($key, $value);
|
||||
}
|
||||
$recentStmt->execute();
|
||||
$recentItems = $recentStmt->fetchAll();
|
||||
|
||||
$listStmt = db()->prepare(
|
||||
'SELECT id, owner_name, owner_role, department_name, period_name, objective_title, key_result_title, score_percent, status, approval_state, updated_at
|
||||
FROM okr_items
|
||||
WHERE ' . $scopeClause . '
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 24'
|
||||
);
|
||||
foreach ($scopeParams as $key => $value) {
|
||||
$listStmt->bindValue($key, $value);
|
||||
}
|
||||
$listStmt->execute();
|
||||
$okrItems = $listStmt->fetchAll();
|
||||
|
||||
$myStmt = db()->prepare(
|
||||
'SELECT id, objective_title, key_result_title, score_percent, approval_state, updated_at
|
||||
FROM okr_items
|
||||
WHERE ' . $scopeClause . ' AND owner_email = :owner_email
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 6'
|
||||
);
|
||||
foreach ($scopeParams as $key => $value) {
|
||||
$myStmt->bindValue($key, $value);
|
||||
}
|
||||
$myStmt->bindValue(':owner_email', $user['email']);
|
||||
$myStmt->execute();
|
||||
$myItems = $myStmt->fetchAll();
|
||||
|
||||
$approvalInbox = [];
|
||||
if (okr_is_approver($user['role'])) {
|
||||
$approvalStmt = db()->prepare(
|
||||
'SELECT id, owner_name, department_name, objective_title, key_result_title, score_percent, updated_at
|
||||
FROM okr_items
|
||||
WHERE ' . $scopeClause . ' AND approval_state = :approval_state
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 5'
|
||||
);
|
||||
foreach ($scopeParams as $key => $value) {
|
||||
$approvalStmt->bindValue($key, $value);
|
||||
}
|
||||
$approvalStmt->bindValue(':approval_state', 'pending_manager');
|
||||
$approvalStmt->execute();
|
||||
$approvalInbox = $approvalStmt->fetchAll();
|
||||
}
|
||||
|
||||
$departmentStmt = db()->prepare(
|
||||
'SELECT department_name, COUNT(*) AS item_count, ROUND(COALESCE(AVG(score_percent), 0), 1) AS department_score
|
||||
FROM okr_items
|
||||
WHERE ' . $scopeClause . '
|
||||
GROUP BY department_name
|
||||
ORDER BY item_count DESC, department_name ASC
|
||||
LIMIT 4'
|
||||
);
|
||||
foreach ($scopeParams as $key => $value) {
|
||||
$departmentStmt->bindValue($key, $value);
|
||||
}
|
||||
$departmentStmt->execute();
|
||||
$departmentRows = $departmentStmt->fetchAll();
|
||||
|
||||
$pendingCount = okr_notification_count($user);
|
||||
$completionRate = ((int) ($summary['total_items'] ?? 0)) > 0 ? round(((int) ($summary['completed_items'] ?? 0) / (int) $summary['total_items']) * 100) : 0;
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= e($projectName) ?> · Workspace</title>
|
||||
<meta name="description" content="<?= e($projectDescription) ?>">
|
||||
<?php if ($projectDescription !== ''): ?>
|
||||
<meta property="og:description" content="<?= e($projectDescription) ?>">
|
||||
<meta property="twitter:description" content="<?= e($projectDescription) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php if ($projectImageUrl !== ''): ?>
|
||||
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
|
||||
<meta property="twitter:image" content="<?= e($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;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
<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=<?= time() ?>">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
</div>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
<body class="app-shell">
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar border-end" id="sidebarMenu">
|
||||
<div class="sidebar-top">
|
||||
<a href="index.php" class="brand-mark">Aligned OKR</a>
|
||||
<div class="small text-secondary mt-2">Tenant: <?= e($user['organization_name']) ?></div>
|
||||
</div>
|
||||
<nav class="nav flex-column gap-1 sidebar-nav">
|
||||
<a class="nav-link active" href="#dashboard">Dashboard</a>
|
||||
<a class="nav-link" href="#corporate-okrs">Corporate OKRs</a>
|
||||
<a class="nav-link" href="#department-okrs">Department OKRs</a>
|
||||
<a class="nav-link" href="#staff-okrs">Staff OKRs</a>
|
||||
<a class="nav-link" href="#my-okrs">My OKRs</a>
|
||||
</nav>
|
||||
<div class="surface-card p-3 mt-auto">
|
||||
<div class="small text-secondary mb-2">Current access</div>
|
||||
<div class="fw-semibold"><?= e($user['role']) ?></div>
|
||||
<div class="small text-secondary"><?= e($user['email']) ?></div>
|
||||
<div class="small text-secondary">Org key: <?= e($user['organization_slug']) ?></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main-panel">
|
||||
<header class="topbar border-bottom">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm d-lg-none" type="button" id="sidebarToggle">Menu</button>
|
||||
<div>
|
||||
<div class="small text-secondary">Operational strategy workspace</div>
|
||||
<h1 class="h4 mb-0">Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="search-wrap">
|
||||
<input type="search" id="tableSearch" class="form-control" placeholder="Search OKRs, people, departments">
|
||||
</div>
|
||||
<button class="btn btn-notify position-relative" type="button" data-bs-toggle="offcanvas" data-bs-target="#notificationsDrawer" aria-controls="notificationsDrawer">
|
||||
Notifications
|
||||
<?php if ($pendingCount > 0): ?>
|
||||
<span class="badge rounded-pill text-bg-dark notification-badge"><?= e((string) $pendingCount) ?></span>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<?= e($user['name']) ?>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
|
||||
<li><span class="dropdown-item-text small text-secondary"><?= e($user['organization_name']) ?></span></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form method="post" action="logout.php" class="px-2">
|
||||
<button class="btn btn-sm btn-outline-danger w-100" type="submit">Log out</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content-area">
|
||||
<section class="hero-panel surface-card" id="dashboard">
|
||||
<div>
|
||||
<p class="tiny-label mb-2">First MVP delivery</p>
|
||||
<h2 class="h3 mb-2">A working OKR workflow for one tenant-aware organization at a time.</h2>
|
||||
<p class="text-secondary mb-0">Create a personal objective, view organization-wide progress, and move items through manager review with auto-scored key results.</p>
|
||||
</div>
|
||||
<div class="hero-meta-grid">
|
||||
<div>
|
||||
<div class="small text-secondary">Workspace</div>
|
||||
<div class="fw-semibold"><?= e($user['organization_name']) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small text-secondary">Role</div>
|
||||
<div class="fw-semibold"><?= e($user['role']) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small text-secondary">Version</div>
|
||||
<div class="fw-semibold">0.1 MVP</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div class="toast-stack">
|
||||
<div class="alert alert-<?= e($flash['type']) ?> border-0 shadow-sm" role="alert" data-auto-dismiss="true">
|
||||
<?= e($flash['message']) ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="stats-grid mt-4">
|
||||
<article class="metric-card surface-card">
|
||||
<div class="small text-secondary">Total OKRs</div>
|
||||
<div class="metric-value"><?= e((string) ($summary['total_items'] ?? 0)) ?></div>
|
||||
<div class="small text-secondary">Scoped to <?= okr_is_super_admin() ? 'all organizations' : 'your organization' ?></div>
|
||||
</article>
|
||||
<article class="metric-card surface-card">
|
||||
<div class="small text-secondary">Pending approvals</div>
|
||||
<div class="metric-value"><?= e((string) ($summary['pending_items'] ?? 0)) ?></div>
|
||||
<div class="small text-secondary">Queue for line-manager review</div>
|
||||
</article>
|
||||
<article class="metric-card surface-card">
|
||||
<div class="small text-secondary">Approved items</div>
|
||||
<div class="metric-value"><?= e((string) ($summary['approved_items'] ?? 0)) ?></div>
|
||||
<div class="small text-secondary">Includes leadership auto-approvals</div>
|
||||
</article>
|
||||
<article class="metric-card surface-card">
|
||||
<div class="small text-secondary">Average score</div>
|
||||
<div class="metric-value"><?= e((string) ($summary['average_score'] ?? 0)) ?>%</div>
|
||||
<div class="small text-secondary">Calculated from key result progress</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="row g-4 mt-1">
|
||||
<div class="col-xl-8">
|
||||
<div class="surface-card h-100" id="corporate-okrs">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<p class="tiny-label mb-2">Corporate OKRs</p>
|
||||
<h2 class="h5 mb-1">Recent strategic objectives</h2>
|
||||
<p class="small text-secondary mb-0">A compact hierarchical view of the latest objectives and key results in scope.</p>
|
||||
</div>
|
||||
<span class="small text-secondary">Use the search field in the header to filter all table rows.</span>
|
||||
</div>
|
||||
<?php if ($recentItems === []): ?>
|
||||
<div class="empty-state border rounded-3 p-4 text-center">
|
||||
<div class="fw-semibold mb-2">No OKRs yet</div>
|
||||
<p class="small text-secondary mb-0">Create your first objective in the My OKRs section to populate the dashboard.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="vstack gap-3">
|
||||
<?php foreach ($recentItems as $item): ?>
|
||||
<a class="list-row text-decoration-none" href="okr_detail.php?id=<?= e((string) $item['id']) ?>">
|
||||
<div>
|
||||
<div class="fw-semibold text-dark"><?= e($item['objective_title']) ?></div>
|
||||
<div class="small text-secondary"><?= e($item['owner_name']) ?> · <?= e($item['owner_role']) ?> · <?= e($item['department_name']) ?></div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge <?= e(okr_badge_class((string) $item['approval_state'])) ?> mb-2"><?= e($item['approval_state']) ?></span>
|
||||
<div class="small fw-semibold"><?= e((string) $item['score_percent']) ?>%</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="surface-card h-100" id="department-okrs">
|
||||
<p class="tiny-label mb-2">Department OKRs</p>
|
||||
<h2 class="h5 mb-3">Distribution by department</h2>
|
||||
<?php if ($departmentRows === []): ?>
|
||||
<p class="small text-secondary mb-0">Department insights appear after your team creates records.</p>
|
||||
<?php else: ?>
|
||||
<div class="vstack gap-3">
|
||||
<?php foreach ($departmentRows as $departmentRow): ?>
|
||||
<div>
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="fw-semibold text-dark"><?= e($departmentRow['department_name']) ?></span>
|
||||
<span class="text-secondary"><?= e((string) $departmentRow['item_count']) ?> OKRs</span>
|
||||
</div>
|
||||
<div class="progress thin-progress" role="progressbar" aria-valuenow="<?= e((string) $departmentRow['department_score']) ?>" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar bg-success" style="width: <?= e((string) $departmentRow['department_score']) ?>%"></div>
|
||||
</div>
|
||||
<div class="small text-secondary mt-1">Average score <?= e((string) $departmentRow['department_score']) ?>%</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<hr>
|
||||
<p class="tiny-label mb-2" id="staff-okrs">Staff OKRs</p>
|
||||
<h3 class="h6 mb-2">Workflow completion</h3>
|
||||
<div class="progress large-progress mb-2" role="progressbar" aria-valuenow="<?= e((string) $completionRate) ?>" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar bg-dark" style="width: <?= e((string) $completionRate) ?>%"></div>
|
||||
</div>
|
||||
<div class="small text-secondary"><?= e((string) $completionRate) ?>% of in-scope objectives are completed.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="row g-4 mt-1">
|
||||
<div class="col-xl-7">
|
||||
<div class="surface-card h-100" id="my-okrs">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-4 flex-wrap">
|
||||
<div>
|
||||
<p class="tiny-label mb-2">My OKRs</p>
|
||||
<h2 class="h5 mb-1">Create a new objective</h2>
|
||||
<p class="small text-secondary mb-0">This thin slice covers create → confirmation → list → detail → approval.</p>
|
||||
</div>
|
||||
<div class="small text-secondary">All writes use PDO prepared statements.</div>
|
||||
</div>
|
||||
<form method="post" class="row g-3" id="okrCreateForm">
|
||||
<input type="hidden" name="action" value="create_okr">
|
||||
<input type="hidden" name="csrf_token" value="<?= e($csrfToken) ?>">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="department_name">Department</label>
|
||||
<input class="form-control" id="department_name" name="department_name" type="text" placeholder="Revenue Operations" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="period_name">OKR period</label>
|
||||
<input class="form-control" id="period_name" name="period_name" type="text" placeholder="Q2 2026" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="objective_title">Objective</label>
|
||||
<input class="form-control" id="objective_title" name="objective_title" type="text" placeholder="Improve enterprise expansion revenue quality" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="key_result_title">Key result</label>
|
||||
<input class="form-control" id="key_result_title" name="key_result_title" type="text" placeholder="Increase qualified pipeline conversion from 21% to 33%" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="description">Notes</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="4" placeholder="Add success criteria, dependencies, and any approval notes."></textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="target_value">Target value</label>
|
||||
<input class="form-control js-score-target" id="target_value" name="target_value" type="number" min="1" step="0.1" value="100" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="current_value">Current value</label>
|
||||
<input class="form-control js-score-current" id="current_value" name="current_value" type="number" min="0" step="0.1" value="0" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Projected score</label>
|
||||
<div class="score-preview surface-muted">
|
||||
<strong class="js-score-output">0%</strong>
|
||||
<span class="small text-secondary">Calculated automatically</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-brand" type="submit">Create objective</button>
|
||||
<a class="btn btn-outline-secondary" href="#okr-table">View current records</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-5">
|
||||
<div class="surface-card h-100">
|
||||
<p class="tiny-label mb-2">Approval inbox</p>
|
||||
<h2 class="h5 mb-3">Items waiting for review</h2>
|
||||
<?php if (!okr_is_approver($user['role'])): ?>
|
||||
<div class="alert alert-light border small mb-0">Only Manager, Director, CEO, Admin, and Super Admin roles can approve or reject submitted OKRs in this first release.</div>
|
||||
<?php elseif ($approvalInbox === []): ?>
|
||||
<div class="empty-state border rounded-3 p-4 text-center">
|
||||
<div class="fw-semibold mb-2">Inbox is clear</div>
|
||||
<p class="small text-secondary mb-0">Pending approvals will appear here as staff submit new OKRs.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="vstack gap-3">
|
||||
<?php foreach ($approvalInbox as $pending): ?>
|
||||
<div class="surface-muted p-3 rounded-3">
|
||||
<div class="d-flex justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="fw-semibold"><?= e($pending['objective_title']) ?></div>
|
||||
<div class="small text-secondary"><?= e($pending['owner_name']) ?> · <?= e($pending['department_name']) ?></div>
|
||||
</div>
|
||||
<div class="text-end small">
|
||||
<div class="fw-semibold"><?= e((string) $pending['score_percent']) ?>%</div>
|
||||
<div class="text-secondary">Current score</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a class="btn btn-sm btn-outline-dark" href="okr_detail.php?id=<?= e((string) $pending['id']) ?>">Review item</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-card mt-4" id="okr-table">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-4 flex-wrap">
|
||||
<div>
|
||||
<p class="tiny-label mb-2">Shared list</p>
|
||||
<h2 class="h5 mb-1">In-scope OKR records</h2>
|
||||
<p class="small text-secondary mb-0">Each record opens a detail page for approvals, comments, and score updates.</p>
|
||||
</div>
|
||||
<div class="small text-secondary">Showing up to 24 most recent records.</div>
|
||||
</div>
|
||||
<?php if ($okrItems === []): ?>
|
||||
<div class="empty-state border rounded-3 p-5 text-center">
|
||||
<div class="fw-semibold mb-2">Your workspace is ready for the first OKR</div>
|
||||
<p class="small text-secondary mb-0">Create one above to activate the dashboard, approval inbox, and analytics cards.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0" id="okrTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Department</th>
|
||||
<th>Objective</th>
|
||||
<th>Score</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($okrItems as $item): ?>
|
||||
<tr class="js-search-row">
|
||||
<td>
|
||||
<div class="fw-semibold"><?= e($item['owner_name']) ?></div>
|
||||
<div class="small text-secondary"><?= e($item['owner_role']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div><?= e($item['department_name']) ?></div>
|
||||
<div class="small text-secondary"><?= e($item['period_name']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= e($item['objective_title']) ?></div>
|
||||
<div class="small text-secondary"><?= e($item['key_result_title']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= e((string) $item['score_percent']) ?>%</div>
|
||||
<div class="small text-secondary">Updated <?= e(date('M j', strtotime((string) $item['updated_at']))) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge <?= e(okr_badge_class((string) $item['approval_state'])) ?> mb-2"><?= e($item['approval_state']) ?></span>
|
||||
<div class="small text-secondary text-capitalize"><?= e($item['status']) ?></div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-sm btn-outline-dark" href="okr_detail.php?id=<?= e((string) $item['id']) ?>">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="surface-card mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<p class="tiny-label mb-2">Personal queue</p>
|
||||
<h2 class="h5 mb-1">Your latest submissions</h2>
|
||||
<p class="small text-secondary mb-0">Quick access to your own items inside the current organization scope.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="#my-okrs">Create another</a>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<?php if ($myItems === []): ?>
|
||||
<div class="col-12">
|
||||
<div class="empty-state border rounded-3 p-4 text-center small text-secondary">No personal OKRs created yet in this workspace.</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($myItems as $item): ?>
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<a class="surface-muted p-3 rounded-3 h-100 d-block text-decoration-none text-dark" href="okr_detail.php?id=<?= e((string) $item['id']) ?>">
|
||||
<div class="fw-semibold mb-2"><?= e($item['objective_title']) ?></div>
|
||||
<div class="small text-secondary mb-3"><?= e($item['key_result_title']) ?></div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="badge <?= e(okr_badge_class((string) $item['approval_state'])) ?>"><?= e($item['approval_state']) ?></span>
|
||||
<span class="fw-semibold"><?= e((string) $item['score_percent']) ?>%</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer-bar border-top">
|
||||
<div>Aligned OKR Cloud · Version 0.1 MVP</div>
|
||||
<div>© <?= e(date('Y')) ?> <?= e($user['organization_name']) ?> workspace</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="notificationsDrawer" aria-labelledby="notificationsDrawerLabel">
|
||||
<div class="offcanvas-header">
|
||||
<h2 class="offcanvas-title h5" id="notificationsDrawerLabel">Notifications</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<div class="surface-muted rounded-3 p-3 mb-3">
|
||||
<div class="fw-semibold mb-1">Approval workload</div>
|
||||
<div class="small text-secondary"><?= e((string) $pendingCount) ?> item(s) are currently waiting for a line manager or leadership decision.</div>
|
||||
</div>
|
||||
<div class="small text-secondary">This initial delivery uses lightweight refreshes and contextual alerts. Real-time comment streams and richer notifications can be layered onto the same workflow next.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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=<?= time() ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
176
login.php
Normal file
176
login.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/okr_bootstrap.php';
|
||||
okr_ensure_schema();
|
||||
|
||||
if (!empty($_SESSION['okr_user'])) {
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
okr_verify_csrf();
|
||||
|
||||
$organizationName = trim((string) ($_POST['organization_name'] ?? ''));
|
||||
$organizationSlug = strtolower(trim((string) ($_POST['organization_slug'] ?? '')));
|
||||
$fullName = trim((string) ($_POST['full_name'] ?? ''));
|
||||
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
|
||||
$role = trim((string) ($_POST['role'] ?? 'Staff'));
|
||||
|
||||
if ($organizationName === '' || strlen($organizationName) < 2) {
|
||||
$errors[] = 'Enter a valid organization name.';
|
||||
}
|
||||
|
||||
if ($organizationSlug === '' || !preg_match('/^[a-z0-9-]{3,40}$/', $organizationSlug)) {
|
||||
$errors[] = 'Use a lowercase org key with letters, numbers, or dashes.';
|
||||
}
|
||||
|
||||
if ($fullName === '' || strlen($fullName) < 2) {
|
||||
$errors[] = 'Enter your full name.';
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Enter a valid work email address.';
|
||||
}
|
||||
|
||||
if (!in_array($role, okr_roles(), true)) {
|
||||
$errors[] = 'Choose a valid role.';
|
||||
}
|
||||
|
||||
if ($errors === []) {
|
||||
$_SESSION['okr_user'] = [
|
||||
'organization_name' => $organizationName,
|
||||
'organization_slug' => $organizationSlug,
|
||||
'name' => $fullName,
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
];
|
||||
|
||||
okr_flash('success', 'Welcome back. Your workspace is ready.');
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$errors[] = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$projectName = okr_app_name();
|
||||
$projectDescription = okr_meta_description();
|
||||
$projectImageUrl = env_value('PROJECT_IMAGE_URL');
|
||||
$csrfToken = okr_csrf_token();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= e($projectName) ?> · Secure sign in</title>
|
||||
<meta name="description" content="<?= e($projectDescription) ?>">
|
||||
<?php if ($projectDescription !== ''): ?>
|
||||
<meta property="og:description" content="<?= e($projectDescription) ?>">
|
||||
<meta property="twitter:description" content="<?= e($projectDescription) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl !== ''): ?>
|
||||
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
|
||||
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
|
||||
<?php endif; ?>
|
||||
<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=<?= time() ?>">
|
||||
</head>
|
||||
<body class="auth-shell">
|
||||
<div class="container-fluid px-0">
|
||||
<div class="row g-0 min-vh-100">
|
||||
<div class="col-lg-6 auth-panel d-flex flex-column justify-content-between p-4 p-lg-5">
|
||||
<div>
|
||||
<div class="d-flex align-items-center justify-content-between mb-5">
|
||||
<a class="brand-mark" href="login.php">Aligned OKR</a>
|
||||
<span class="eyebrow-tag">Multi-tenant SaaS</span>
|
||||
</div>
|
||||
<div class="hero-copy">
|
||||
<p class="text-uppercase tiny-label mb-3">Strategic execution platform</p>
|
||||
<h1 class="display-title mb-3">Run one tenant-aware OKR workflow from sign-in to approval.</h1>
|
||||
<p class="text-secondary mb-4">This initial release gives each organization an isolated workspace with a clean dashboard, scoped OKR records, manager approvals, and lightweight analytics.</p>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-sm-6">
|
||||
<div class="surface-card h-100 p-3">
|
||||
<div class="small text-secondary mb-2">Included now</div>
|
||||
<div class="fw-semibold mb-1">My OKRs workflow</div>
|
||||
<p class="small text-secondary mb-0">Create objectives, track key results, and route them into approval with automatic score calculation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="surface-card h-100 p-3">
|
||||
<div class="small text-secondary mb-2">Ready for managers</div>
|
||||
<div class="fw-semibold mb-1">Approval inbox</div>
|
||||
<p class="small text-secondary mb-0">Managers, directors, CEOs, and super admins can review progress, leave feedback, and approve or reject.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small text-secondary mt-4">Version 0.1 · Designed for secure internal planning and execution.</div>
|
||||
</div>
|
||||
<div class="col-lg-6 d-flex align-items-center justify-content-center p-4 p-lg-5 auth-form-column">
|
||||
<div class="surface-card auth-card w-100">
|
||||
<div class="mb-4">
|
||||
<p class="text-uppercase tiny-label mb-2">Secure workspace access</p>
|
||||
<h2 class="h3 mb-2">Sign in to your organization</h2>
|
||||
<p class="text-secondary mb-0">Use a work email and role to enter an isolated tenant workspace.</p>
|
||||
</div>
|
||||
|
||||
<?php if ($errors !== []): ?>
|
||||
<div class="alert alert-danger border-0 small" role="alert">
|
||||
<strong>Please fix the following:</strong>
|
||||
<ul class="mb-0 mt-2 ps-3">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<li><?= e($error) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf_token" value="<?= e($csrfToken) ?>">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="organization_name">Organization name</label>
|
||||
<input class="form-control" id="organization_name" name="organization_name" type="text" placeholder="Northstar Holdings" value="<?= e($_POST['organization_name'] ?? '') ?>" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="organization_slug">Organization key</label>
|
||||
<input class="form-control" id="organization_slug" name="organization_slug" type="text" placeholder="northstar-holdings" value="<?= e($_POST['organization_slug'] ?? '') ?>" required>
|
||||
<div class="form-text">Used to isolate all OKR records for this organization.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="full_name">Full name</label>
|
||||
<input class="form-control" id="full_name" name="full_name" type="text" placeholder="Morgan Lee" value="<?= e($_POST['full_name'] ?? '') ?>" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="email">Work email</label>
|
||||
<input class="form-control" id="email" name="email" type="email" placeholder="morgan@northstar.com" value="<?= e($_POST['email'] ?? '') ?>" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="role">Role</label>
|
||||
<select class="form-select" id="role" name="role" required>
|
||||
<?php foreach (okr_roles() as $roleOption): ?>
|
||||
<option value="<?= e($roleOption) ?>" <?= ($roleOption === ($_POST['role'] ?? 'Manager')) ? 'selected' : '' ?>><?= e($roleOption) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 d-grid gap-2 mt-2">
|
||||
<button class="btn btn-brand" type="submit">Enter workspace</button>
|
||||
<div class="small text-secondary">Roles included: Admin, CEO, Director, Manager, Team, Staff, plus Super Admin for SaaS oversight.</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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=<?= time() ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
13
logout.php
Normal file
13
logout.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool) $params['secure'], (bool) $params['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
176
okr_bootstrap.php
Normal file
176
okr_bootstrap.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
@date_default_timezone_set('UTC');
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
const OKR_ROLES = ['Super Admin', 'Admin', 'CEO', 'Director', 'Manager', 'Team', 'Staff'];
|
||||
const OKR_APPROVER_ROLES = ['Super Admin', 'Admin', 'CEO', 'Director', 'Manager'];
|
||||
|
||||
function env_value(string $key, string $default = ''): string
|
||||
{
|
||||
$serverValue = $_SERVER[$key] ?? null;
|
||||
if (is_string($serverValue) && $serverValue !== '') {
|
||||
return $serverValue;
|
||||
}
|
||||
|
||||
$envValue = getenv($key);
|
||||
return is_string($envValue) && $envValue !== '' ? $envValue : $default;
|
||||
}
|
||||
|
||||
function e(mixed $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function okr_app_name(): string
|
||||
{
|
||||
return env_value('PROJECT_NAME', 'Aligned OKR Cloud');
|
||||
}
|
||||
|
||||
function okr_meta_description(): string
|
||||
{
|
||||
return env_value('PROJECT_DESCRIPTION', 'Multi-tenant OKR workspace for strategy, approvals, and score tracking.');
|
||||
}
|
||||
|
||||
function okr_roles(): array
|
||||
{
|
||||
return OKR_ROLES;
|
||||
}
|
||||
|
||||
function okr_is_approver(string $role): bool
|
||||
{
|
||||
return in_array($role, OKR_APPROVER_ROLES, true);
|
||||
}
|
||||
|
||||
function okr_is_super_admin(): bool
|
||||
{
|
||||
return (($_SESSION['okr_user']['role'] ?? '') === 'Super Admin');
|
||||
}
|
||||
|
||||
function okr_current_user(): array
|
||||
{
|
||||
if (empty($_SESSION['okr_user']) || !is_array($_SESSION['okr_user'])) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
return $_SESSION['okr_user'];
|
||||
}
|
||||
|
||||
function okr_flash(string $type, string $message): void
|
||||
{
|
||||
$_SESSION['okr_flash'] = [
|
||||
'type' => $type,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
function okr_pull_flash(): ?array
|
||||
{
|
||||
$flash = $_SESSION['okr_flash'] ?? null;
|
||||
unset($_SESSION['okr_flash']);
|
||||
|
||||
return is_array($flash) ? $flash : null;
|
||||
}
|
||||
|
||||
function okr_csrf_token(): string
|
||||
{
|
||||
if (empty($_SESSION['okr_csrf'])) {
|
||||
$_SESSION['okr_csrf'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
return (string) $_SESSION['okr_csrf'];
|
||||
}
|
||||
|
||||
function okr_verify_csrf(): void
|
||||
{
|
||||
$sessionToken = $_SESSION['okr_csrf'] ?? '';
|
||||
$postedToken = $_POST['csrf_token'] ?? '';
|
||||
|
||||
if (!is_string($postedToken) || !hash_equals((string) $sessionToken, $postedToken)) {
|
||||
throw new RuntimeException('Security validation failed. Please refresh and try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function okr_scope_clause(string $alias = ''): string
|
||||
{
|
||||
$prefix = $alias !== '' ? $alias . '.' : '';
|
||||
return okr_is_super_admin() ? '1=1' : $prefix . 'organization_slug = :organization_slug';
|
||||
}
|
||||
|
||||
function okr_scope_params(array $user): array
|
||||
{
|
||||
return okr_is_super_admin() ? [] : [':organization_slug' => $user['organization_slug']];
|
||||
}
|
||||
|
||||
function okr_calculate_score(float $currentValue, float $targetValue): float
|
||||
{
|
||||
if ($targetValue <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$score = ($currentValue / $targetValue) * 100;
|
||||
return round(max(0, min(100, $score)), 1);
|
||||
}
|
||||
|
||||
function okr_badge_class(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
'approved', 'completed', 'active' => 'badge-soft-success',
|
||||
'pending_manager', 'submitted' => 'badge-soft-warning',
|
||||
'rejected', 'needs_revision' => 'badge-soft-danger',
|
||||
default => 'badge-soft-neutral',
|
||||
};
|
||||
}
|
||||
|
||||
function okr_notification_count(array $user): int
|
||||
{
|
||||
if (!okr_is_approver($user['role'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$params = okr_scope_params($user);
|
||||
$stmt = db()->prepare('SELECT COUNT(*) FROM okr_items WHERE ' . okr_scope_clause() . ' AND approval_state = :approval_state');
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue($key, $value);
|
||||
}
|
||||
$stmt->bindValue(':approval_state', 'pending_manager');
|
||||
$stmt->execute();
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function okr_ensure_schema(): void
|
||||
{
|
||||
db()->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS okr_items (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
organization_name VARCHAR(120) NOT NULL,
|
||||
organization_slug VARCHAR(120) NOT NULL,
|
||||
owner_name VARCHAR(120) NOT NULL,
|
||||
owner_email VARCHAR(160) NOT NULL,
|
||||
owner_role VARCHAR(40) NOT NULL,
|
||||
department_name VARCHAR(120) NOT NULL,
|
||||
period_name VARCHAR(120) NOT NULL,
|
||||
objective_title VARCHAR(255) NOT NULL,
|
||||
key_result_title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
target_value DECIMAL(10,2) NOT NULL DEFAULT 100.00,
|
||||
current_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
score_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'draft',
|
||||
approval_state VARCHAR(40) NOT NULL DEFAULT 'pending_manager',
|
||||
manager_comment TEXT NULL,
|
||||
created_by_email VARCHAR(160) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_scope (organization_slug, department_name, approval_state),
|
||||
INDEX idx_owner (owner_email, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
}
|
||||
249
okr_detail.php
Normal file
249
okr_detail.php
Normal file
@ -0,0 +1,249 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/okr_bootstrap.php';
|
||||
okr_ensure_schema();
|
||||
$user = okr_current_user();
|
||||
|
||||
$scopeClause = okr_scope_clause();
|
||||
$scopeParams = okr_scope_params($user);
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
okr_flash('danger', 'Select a valid OKR record.');
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'review_okr') {
|
||||
try {
|
||||
okr_verify_csrf();
|
||||
|
||||
if (!okr_is_approver($user['role'])) {
|
||||
throw new RuntimeException('Your current role cannot approve or reject OKRs in this release.');
|
||||
}
|
||||
|
||||
$decision = trim((string) ($_POST['decision'] ?? 'update'));
|
||||
$currentValue = (float) ($_POST['current_value'] ?? 0);
|
||||
$targetValue = (float) ($_POST['target_value'] ?? 0);
|
||||
$managerComment = trim((string) ($_POST['manager_comment'] ?? ''));
|
||||
|
||||
if ($targetValue <= 0) {
|
||||
throw new RuntimeException('Target value must stay above 0.');
|
||||
}
|
||||
if ($currentValue < 0) {
|
||||
throw new RuntimeException('Current value cannot be negative.');
|
||||
}
|
||||
|
||||
$scorePercent = okr_calculate_score($currentValue, $targetValue);
|
||||
$approvalState = 'pending_manager';
|
||||
$status = 'submitted';
|
||||
|
||||
if ($decision === 'approve') {
|
||||
$approvalState = 'approved';
|
||||
$status = $scorePercent >= 100 ? 'completed' : 'active';
|
||||
if ($managerComment === '') {
|
||||
$managerComment = 'Approved and scored by ' . $user['role'] . '.';
|
||||
}
|
||||
} elseif ($decision === 'reject') {
|
||||
$approvalState = 'rejected';
|
||||
$status = 'needs_revision';
|
||||
if ($managerComment === '') {
|
||||
$managerComment = 'Rejected with feedback from ' . $user['role'] . '.';
|
||||
}
|
||||
} elseif ($managerComment === '') {
|
||||
$managerComment = 'Progress updated by ' . $user['role'] . '.';
|
||||
}
|
||||
|
||||
$sql = 'UPDATE okr_items SET current_value = :current_value, target_value = :target_value, score_percent = :score_percent, approval_state = :approval_state, status = :status, manager_comment = :manager_comment WHERE id = :id AND ' . $scopeClause;
|
||||
$stmt = db()->prepare($sql);
|
||||
$stmt->bindValue(':current_value', $currentValue);
|
||||
$stmt->bindValue(':target_value', $targetValue);
|
||||
$stmt->bindValue(':score_percent', $scorePercent);
|
||||
$stmt->bindValue(':approval_state', $approvalState);
|
||||
$stmt->bindValue(':status', $status);
|
||||
$stmt->bindValue(':manager_comment', $managerComment);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
foreach ($scopeParams as $key => $value) {
|
||||
$stmt->bindValue($key, $value);
|
||||
}
|
||||
$stmt->execute();
|
||||
|
||||
okr_flash('success', 'OKR updated successfully.');
|
||||
header('Location: okr_detail.php?id=' . $id);
|
||||
exit;
|
||||
} catch (Throwable $exception) {
|
||||
okr_flash('danger', $exception->getMessage());
|
||||
header('Location: okr_detail.php?id=' . $id);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$sql = 'SELECT * FROM okr_items WHERE id = :id AND ' . $scopeClause . ' LIMIT 1';
|
||||
$stmt = db()->prepare($sql);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
foreach ($scopeParams as $key => $value) {
|
||||
$stmt->bindValue($key, $value);
|
||||
}
|
||||
$stmt->execute();
|
||||
$item = $stmt->fetch();
|
||||
|
||||
if (!$item) {
|
||||
okr_flash('danger', 'That OKR could not be found in your current scope.');
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$projectName = okr_app_name();
|
||||
$projectDescription = okr_meta_description();
|
||||
$projectImageUrl = env_value('PROJECT_IMAGE_URL');
|
||||
$flash = okr_pull_flash();
|
||||
$csrfToken = okr_csrf_token();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= e($projectName) ?> · OKR detail</title>
|
||||
<meta name="description" content="<?= e($projectDescription) ?>">
|
||||
<?php if ($projectDescription !== ''): ?>
|
||||
<meta property="og:description" content="<?= e($projectDescription) ?>">
|
||||
<meta property="twitter:description" content="<?= e($projectDescription) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl !== ''): ?>
|
||||
<meta property="og:image" content="<?= e($projectImageUrl) ?>">
|
||||
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>">
|
||||
<?php endif; ?>
|
||||
<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=<?= time() ?>">
|
||||
</head>
|
||||
<body class="detail-shell">
|
||||
<div class="container py-4 py-lg-5">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<p class="tiny-label mb-2">OKR detail</p>
|
||||
<h1 class="h3 mb-1"><?= e($item['objective_title']) ?></h1>
|
||||
<p class="text-secondary mb-0"><?= e($item['organization_name']) ?> · <?= e($item['department_name']) ?> · <?= e($item['period_name']) ?></p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a class="btn btn-outline-secondary" href="index.php">Back to workspace</a>
|
||||
<form method="post" action="logout.php">
|
||||
<button class="btn btn-outline-danger" type="submit">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div class="alert alert-<?= e($flash['type']) ?> border-0 shadow-sm" role="alert">
|
||||
<?= e($flash['message']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-xl-8">
|
||||
<section class="surface-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<p class="tiny-label mb-2">Key result</p>
|
||||
<h2 class="h5 mb-1"><?= e($item['key_result_title']) ?></h2>
|
||||
<p class="small text-secondary mb-0">Owner: <?= e($item['owner_name']) ?> · <?= e($item['owner_role']) ?> · <?= e($item['owner_email']) ?></p>
|
||||
</div>
|
||||
<span class="badge <?= e(okr_badge_class((string) $item['approval_state'])) ?>"><?= e($item['approval_state']) ?></span>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="surface-muted p-3 rounded-3 h-100">
|
||||
<div class="small text-secondary mb-1">Current score</div>
|
||||
<div class="h4 mb-0"><?= e((string) $item['score_percent']) ?>%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="surface-muted p-3 rounded-3 h-100">
|
||||
<div class="small text-secondary mb-1">Current value</div>
|
||||
<div class="h4 mb-0"><?= e((string) $item['current_value']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="surface-muted p-3 rounded-3 h-100">
|
||||
<div class="small text-secondary mb-1">Target value</div>
|
||||
<div class="h4 mb-0"><?= e((string) $item['target_value']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="fw-semibold text-dark">Progress to target</span>
|
||||
<span class="text-secondary"><?= e((string) $item['score_percent']) ?>%</span>
|
||||
</div>
|
||||
<div class="progress large-progress" role="progressbar" aria-valuenow="<?= e((string) $item['score_percent']) ?>" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar bg-success" style="width: <?= e((string) $item['score_percent']) ?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="small text-secondary mb-2">Objective notes</div>
|
||||
<div class="surface-muted rounded-3 p-3 small">
|
||||
<?= nl2br(e($item['description'] ?: 'No additional notes supplied.')) ?>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small text-secondary mb-2">Latest reviewer comment</div>
|
||||
<div class="surface-muted rounded-3 p-3 small">
|
||||
<?= nl2br(e($item['manager_comment'] ?: 'No comments yet.')) ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<section class="surface-card mb-4">
|
||||
<p class="tiny-label mb-2">Approval workflow</p>
|
||||
<h2 class="h5 mb-3">Review and score</h2>
|
||||
<form method="post" class="row g-3" id="okrReviewForm">
|
||||
<input type="hidden" name="action" value="review_okr">
|
||||
<input type="hidden" name="csrf_token" value="<?= e($csrfToken) ?>">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="detail_target_value">Target value</label>
|
||||
<input class="form-control js-score-target" id="detail_target_value" name="target_value" type="number" min="1" step="0.1" value="<?= e((string) $item['target_value']) ?>" <?= okr_is_approver($user['role']) ? '' : 'disabled' ?> >
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="detail_current_value">Current value</label>
|
||||
<input class="form-control js-score-current" id="detail_current_value" name="current_value" type="number" min="0" step="0.1" value="<?= e((string) $item['current_value']) ?>" <?= okr_is_approver($user['role']) ? '' : 'disabled' ?> >
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="manager_comment">Comment</label>
|
||||
<textarea class="form-control" id="manager_comment" name="manager_comment" rows="4" <?= okr_is_approver($user['role']) ? '' : 'disabled' ?>><?= e((string) ($item['manager_comment'] ?? '')) ?></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Projected score</label>
|
||||
<div class="score-preview surface-muted">
|
||||
<strong class="js-score-output"><?= e((string) $item['score_percent']) ?>%</strong>
|
||||
<span class="small text-secondary">Recomputed live in the browser</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (okr_is_approver($user['role'])): ?>
|
||||
<div class="col-12 d-grid gap-2">
|
||||
<button class="btn btn-brand" type="submit" name="decision" value="approve">Approve and score</button>
|
||||
<button class="btn btn-outline-secondary" type="submit" name="decision" value="update">Save progress only</button>
|
||||
<button class="btn btn-outline-danger" type="submit" name="decision" value="reject">Reject with feedback</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="alert alert-light border small mb-0">You can view this record, but only leadership roles can change approval status in this first release.</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</section>
|
||||
<section class="surface-card">
|
||||
<p class="tiny-label mb-2">Audit snapshot</p>
|
||||
<ul class="list-unstyled small mb-0 vstack gap-2 text-secondary">
|
||||
<li><strong class="text-dark">Created:</strong> <?= e(date('M j, Y H:i', strtotime((string) $item['created_at']))) ?> UTC</li>
|
||||
<li><strong class="text-dark">Updated:</strong> <?= e(date('M j, Y H:i', strtotime((string) $item['updated_at']))) ?> UTC</li>
|
||||
<li><strong class="text-dark">Status:</strong> <?= e((string) $item['status']) ?></li>
|
||||
<li><strong class="text-dark">Approval:</strong> <?= e((string) $item['approval_state']) ?></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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=<?= time() ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user