Scoped to current company only.
+diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..f07267d 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,809 @@ +:root { + color-scheme: light; + --bg: #f5fbff; + --surface: #ffffff; + --surface-muted: #f7fbfd; + --surface-accent: linear-gradient(135deg, rgba(19, 115, 230, 0.08), rgba(22, 163, 74, 0.08)); + --border: #d8e5ef; + --border-strong: #bdd3e2; + --text: #102a43; + --text-soft: #5b7083; + --accent: #1373e6; + --accent-strong: #0b57d0; + --accent-soft: #eaf4ff; + --accent-soft-strong: #d6e9ff; + --mint: #16a34a; + --mint-soft: #eaf9ef; + --success: #15803d; + --warning: #b7791f; + --danger: #c2413b; + --shadow: 0 20px 50px rgba(15, 42, 79, 0.08); + --shadow-soft: 0 12px 26px rgba(19, 115, 230, 0.08); + --shadow-hover: 0 24px 60px rgba(19, 115, 230, 0.12); + --radius-sm: 10px; + --radius-md: 18px; + --radius-lg: 24px; + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --sidebar-width: 290px; + --ease: 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +html, +body { + min-height: 100%; + 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; + color: var(--text); + font-family: "Tajawal", "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + background: + radial-gradient(circle at top left, rgba(19, 115, 230, 0.10), transparent 30%), + radial-gradient(circle at top right, rgba(22, 163, 74, 0.09), transparent 28%), + linear-gradient(180deg, #fbfeff 0%, var(--bg) 58%, #f8fcff 100%); +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.18), transparent 35%, rgba(19, 115, 230, 0.03) 100%); + z-index: -1; +} + +a { + color: inherit; + transition: color var(--ease), opacity var(--ease); +} + +a:hover { + color: var(--accent-strong); +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.875em; + background: var(--accent-soft); + color: var(--accent-strong); + padding: 0.12rem 0.35rem; + border-radius: 6px; +} + +.app-shell { + display: flex; min-height: 100vh; } -.main-wrapper { +.sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + background: rgba(255, 255, 255, 0.78); + border-right: 1px solid rgba(189, 211, 226, 0.85); + padding: var(--space-5); display: flex; + flex-direction: column; + justify-content: space-between; + position: sticky; + top: 0; + height: 100vh; + backdrop-filter: blur(18px); +} + +.brand-mark { + display: flex; + align-items: center; + gap: 0.95rem; +} + +.brand-mark:hover .brand-icon { + transform: translateY(-2px) scale(1.02); +} + +.brand-icon { + display: inline-flex; align-items: center; justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; + width: 2.75rem; + height: 2.75rem; + border-radius: 14px; + background: linear-gradient(135deg, var(--accent) 0%, var(--mint) 100%); + color: #fff; + font-weight: 800; + font-size: 1rem; + box-shadow: 0 18px 35px rgba(19, 115, 230, 0.22); + transition: transform var(--ease), box-shadow var(--ease); } -@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); +.sidebar-label, +.eyebrow, +.section-kicker { + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.72rem; font-weight: 700; - font-size: 1.1rem; + color: var(--text-soft); + margin-bottom: 0.45rem; +} + +.nav-link { + padding: 0.82rem 0.95rem; + color: var(--text-soft); + border-radius: 14px; + font-weight: 600; + transition: transform var(--ease), background-color var(--ease), color var(--ease), box-shadow var(--ease); +} + +.nav-link:hover, +.nav-link.active { + background: linear-gradient(135deg, rgba(19, 115, 230, 0.12), rgba(22, 163, 74, 0.10)); + color: var(--accent-strong); + box-shadow: inset 0 0 0 1px rgba(19, 115, 230, 0.08); +} + +.nav-link:hover { + transform: translateX(4px); +} + +.sidebar-section, +.sidebar-footer { + border-top: 1px solid rgba(189, 211, 226, 0.7); + padding-top: var(--space-4); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 999px; + display: inline-block; + background: linear-gradient(135deg, var(--accent) 0%, var(--mint) 100%); + box-shadow: 0 0 0 5px rgba(22, 163, 74, 0.08); + margin-right: 0.5rem; +} + +.roadmap-item { + color: var(--text-soft); + font-size: 0.95rem; + margin-bottom: 0.72rem; +} + +.tenant-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid rgba(19, 115, 230, 0.12); + background: var(--accent-soft); + color: var(--accent-strong); + border-radius: 999px; + padding: 0.42rem 0.82rem; + font-size: 0.8rem; + font-weight: 700; + margin-bottom: 0.8rem; +} + +.content-area { + flex: 1; + min-width: 0; +} + +.topbar { + padding: var(--space-5) var(--space-5) 0; display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; + gap: var(--space-4); + position: sticky; + top: 0; + z-index: 5; + backdrop-filter: blur(14px); + background: linear-gradient(180deg, rgba(245, 251, 255, 0.92) 0%, rgba(245, 251, 255, 0.76) 72%, rgba(245, 251, 255, 0) 100%); } -.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 { +.topbar-actions { display: flex; gap: 0.75rem; + align-items: flex-end; } -.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; +.tenant-switcher { + min-width: 180px; } -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); +.page-title, +.section-title, +.panel-title { + margin: 0; + letter-spacing: -0.03em; } -.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 { - font-size: 14px; - color: #fff; - 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; +.page-title { + font-size: clamp(1.75rem, 1.2rem + 1vw, 2.2rem); font-weight: 800; } -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; +.section-title { + font-size: clamp(1.45rem, 1.12rem + 1vw, 2.1rem); + max-width: 56rem; + font-weight: 800; + line-height: 1.25; } -.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; -} - -.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; -} - -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); -} - -.header-container { - 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); -} - -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; +.panel-title { + font-size: 1.18rem; font-weight: 700; } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.page-content { + padding: var(--space-5); } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; +.hero-panel, +.metric-card, +.panel-card { + background: var(--surface); + border: 1px solid rgba(189, 211, 226, 0.8); + border-radius: var(--radius-md); + box-shadow: var(--shadow); + transition: transform var(--ease), box-shadow var(--ease), border-color var(--ease), background-color var(--ease); } -.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; +.hero-panel:hover, +.metric-card:hover, +.panel-card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-hover); + border-color: rgba(19, 115, 230, 0.18); } -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; +.hero-panel { + position: relative; + overflow: hidden; + padding: var(--space-5); + margin-bottom: var(--space-4); + display: flex; + justify-content: space-between; + gap: var(--space-5); + align-items: flex-end; + background: + radial-gradient(circle at top right, rgba(22, 163, 74, 0.14), transparent 30%), + linear-gradient(135deg, rgba(19, 115, 230, 0.05), rgba(22, 163, 74, 0.05)), + #ffffff; } -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); +.hero-panel::after { + content: ""; + position: absolute; + width: 220px; + height: 220px; + border-radius: 50%; + top: -110px; + right: -90px; + background: linear-gradient(135deg, rgba(19, 115, 230, 0.14), rgba(22, 163, 74, 0.10)); + filter: blur(8px); } -.history-table { - width: 100%; +.hero-panel > * { + position: relative; + z-index: 1; } -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; +.section-copy { + max-width: 52rem; + color: var(--text-soft); + margin: 0; + line-height: 1.9; + font-size: 1rem; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.hero-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.metric-card { + position: relative; + overflow: hidden; + padding: var(--space-4); + height: 100%; } -.no-messages { +.metric-card::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 4px; + background: linear-gradient(90deg, var(--accent) 0%, var(--mint) 100%); +} + +.metric-label, +.metric-footnote, +.form-hint, +.text-secondary { + color: var(--text-soft) !important; +} + +.metric-label { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} + +.metric-value { + font-size: 2rem; + font-weight: 800; + margin: 0.45rem 0 0.3rem; + color: var(--accent-strong); +} + +.metric-footnote { + margin: 0; + font-size: 0.9rem; +} + +.panel-card { + padding: var(--space-5); +} + +.panel-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.empty-state { + border: 1px dashed var(--border-strong); + border-radius: var(--radius-md); + padding: var(--space-5); text-align: center; - color: #777; -} \ No newline at end of file + background: linear-gradient(135deg, rgba(19, 115, 230, 0.04), rgba(22, 163, 74, 0.04)); +} + +.empty-state.compact { + padding: var(--space-4); +} + +.empty-state.left { + text-align: left; +} + +.empty-state h4 { + font-size: 1rem; + margin-bottom: 0.4rem; + font-weight: 700; +} + +.empty-state p { + color: var(--text-soft); + margin-bottom: 0; +} + +.hr-table { + --bs-table-bg: transparent; + --bs-table-striped-bg: rgba(19, 115, 230, 0.02); +} + +.hr-table th { + color: var(--text-soft); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.07em; + border-bottom-color: rgba(189, 211, 226, 0.9); + font-weight: 700; +} + +.hr-table td { + padding-top: 1rem; + padding-bottom: 1rem; + border-bottom-color: rgba(189, 211, 226, 0.7); + transition: background-color var(--ease); +} + +.hr-table tbody tr:hover td { + background: rgba(19, 115, 230, 0.03); +} + +.queue-list, +.trend-list, +.feature-list, +.roadmap-grid { + display: grid; + gap: 0.9rem; +} + +.queue-item { + border: 1px solid rgba(189, 211, 226, 0.8); + border-radius: 14px; + padding: 0.95rem 1rem; + display: flex; + justify-content: space-between; + gap: 0.75rem; + text-decoration: none; + background: linear-gradient(135deg, rgba(19, 115, 230, 0.03), rgba(22, 163, 74, 0.03)); + transition: transform var(--ease), border-color var(--ease), box-shadow var(--ease), background-color var(--ease); +} + +.queue-item:hover { + border-color: rgba(19, 115, 230, 0.25); + background: #fff; + box-shadow: var(--shadow-soft); + transform: translateX(4px); +} + +.queue-date { + color: var(--text-soft); + white-space: nowrap; + font-size: 0.85rem; + font-weight: 600; +} + +.trend-row { + display: grid; + grid-template-columns: 80px 1fr 24px; + align-items: center; + gap: 0.85rem; + font-size: 0.92rem; +} + +.trend-bar-wrap { + width: 100%; + height: 10px; + background: rgba(19, 115, 230, 0.1); + border-radius: 999px; + overflow: hidden; +} + +.trend-bar { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--accent) 0%, var(--mint) 100%); + box-shadow: 0 8px 18px rgba(19, 115, 230, 0.22); + animation: barGrow 900ms cubic-bezier(0.22, 1, 0.36, 1); + transform-origin: left center; +} + +.feature-list { + margin: 0; + padding-left: 1.15rem; + color: var(--text-soft); +} + +.feature-list li::marker { + color: var(--mint); +} + +.roadmap-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.roadmap-card { + border: 1px solid rgba(189, 211, 226, 0.8); + border-radius: 14px; + padding: 1rem; + background: linear-gradient(135deg, rgba(19, 115, 230, 0.035), rgba(22, 163, 74, 0.035)); + transition: transform var(--ease), box-shadow var(--ease), border-color var(--ease); +} + +.roadmap-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-soft); + border-color: rgba(19, 115, 230, 0.18); +} + +.roadmap-card strong, +.roadmap-card span { + display: block; +} + +.roadmap-card strong { + font-weight: 700; +} + +.roadmap-card span { + color: var(--text-soft); + margin-top: 0.3rem; + font-size: 0.92rem; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.95rem; +} + +.detail-card, +.info-block { + border: 1px solid rgba(189, 211, 226, 0.8); + border-radius: 14px; + background: linear-gradient(135deg, rgba(19, 115, 230, 0.03), rgba(22, 163, 74, 0.03)); +} + +.detail-card { + padding: 1rem; +} + +.detail-card span, +.detail-card small { + display: block; + color: var(--text-soft); +} + +.detail-card strong { + display: block; + font-size: 1rem; + margin: 0.3rem 0; + font-weight: 700; +} + +.info-block { + padding: 1rem 1.1rem; +} + +.info-block.danger { + border-color: rgba(194, 65, 59, 0.24); + background: linear-gradient(135deg, rgba(255, 244, 244, 0.95), rgba(255, 250, 250, 1)); +} + +.btn, +.form-control, +.form-select, +.alert, +.badge { + border-radius: 12px; +} + +.btn { + font-weight: 700; + padding: 0.78rem 1.15rem; + transition: transform var(--ease), box-shadow var(--ease), border-color var(--ease), background-color var(--ease), color var(--ease); +} + +.btn:hover { + transform: translateY(-2px); +} + +.btn-dark { + background: linear-gradient(135deg, var(--accent) 0%, var(--mint) 100%); + border: none; + box-shadow: 0 16px 32px rgba(19, 115, 230, 0.18); +} + +.btn-dark:hover, +.btn-dark:focus { + background: linear-gradient(135deg, var(--accent-strong) 0%, #15803d 100%); + box-shadow: 0 20px 36px rgba(19, 115, 230, 0.24); +} + +.btn-outline-secondary, +.btn-outline-dark { + border-color: rgba(19, 115, 230, 0.22); + color: var(--accent-strong); + background: rgba(255, 255, 255, 0.88); +} + +.btn-outline-secondary:hover, +.btn-outline-secondary:focus, +.btn-outline-dark:hover, +.btn-outline-dark:focus { + border-color: rgba(19, 115, 230, 0.34); + color: var(--accent-strong); + background: var(--accent-soft); + box-shadow: var(--shadow-soft); +} + +.btn-outline-danger { + border-color: rgba(194, 65, 59, 0.28); + color: var(--danger); +} + +.btn-outline-danger:hover, +.btn-outline-danger:focus { + background: #fff4f3; + color: #9f2d28; +} + +.form-label { + font-weight: 700; + color: var(--text); + margin-bottom: 0.45rem; +} + +.form-control, +.form-select { + border-color: rgba(189, 211, 226, 0.95); + padding: 0.82rem 0.95rem; + box-shadow: none; + background-color: rgba(255, 255, 255, 0.92); + transition: border-color var(--ease), box-shadow var(--ease), background-color var(--ease), transform var(--ease); +} + +.form-control:hover, +.form-select:hover { + border-color: rgba(19, 115, 230, 0.26); +} + +.form-control:focus, +.form-select:focus, +.btn:focus, +.nav-link:focus { + box-shadow: 0 0 0 0.24rem rgba(19, 115, 230, 0.14); + border-color: rgba(19, 115, 230, 0.5); +} + +.form-control.is-invalid, +.form-select.is-invalid { + border-color: rgba(194, 65, 59, 0.46); + background-image: none; +} + +.form-hint { + font-size: 0.84rem; +} + +.request-preview { + min-width: 180px; +} + +.toast-stack { + padding: 0 var(--space-5); + margin-top: var(--space-4); +} + +.alert { + border-width: 1px; + box-shadow: var(--shadow-soft); +} + +.alert-success { + border-color: rgba(22, 163, 74, 0.18); + background: #effcf5; + color: #166534; +} + +.alert-warning { + border-color: rgba(183, 121, 31, 0.2); + background: #fff8e7; + color: #8a5c15; +} + +.alert-danger { + border-color: rgba(194, 65, 59, 0.2); + background: #fff5f4; + color: #9f2d28; +} + +.badge { + padding: 0.6rem 0.72rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.badge.text-bg-success { + background: var(--mint-soft) !important; + color: #177245 !important; +} + +.badge.text-bg-danger { + background: #fff1f0 !important; + color: #b42318 !important; +} + +.badge.text-bg-warning { + background: var(--accent-soft) !important; + color: var(--accent-strong) !important; +} + +.js-reveal { + opacity: 0; + transform: translateY(18px) scale(0.985); + transition: opacity 560ms cubic-bezier(0.22, 1, 0.36, 1), transform 560ms cubic-bezier(0.22, 1, 0.36, 1); + transition-delay: var(--reveal-delay, 0ms); +} + +.js-reveal.is-visible { + opacity: 1; + transform: translateY(0) scale(1); +} + +@keyframes barGrow { + from { + transform: scaleX(0.3); + opacity: 0.65; + } + to { + transform: scaleX(1); + opacity: 1; + } +} + +@media (max-width: 991.98px) { + .topbar, + .page-content, + .toast-stack { + padding-left: 1rem; + padding-right: 1rem; + } + + .topbar { + position: static; + background: transparent; + backdrop-filter: none; + } + + .hero-panel, + .topbar, + .panel-head { + flex-direction: column; + align-items: stretch; + } + + .topbar-actions, + .hero-actions { + width: 100%; + } + + .topbar-actions > * { + flex: 1; + } + + .tenant-switcher { + min-width: 0; + } +} + +@media (max-width: 767.98px) { + .page-content { + padding-top: 1rem; + padding-bottom: 1.5rem; + } + + .panel-card, + .hero-panel { + padding: 1rem; + } + + .detail-grid, + .roadmap-grid { + grid-template-columns: 1fr; + } + + .trend-row { + grid-template-columns: 72px 1fr 18px; + gap: 0.55rem; + font-size: 0.86rem; + } + + .btn { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + html, + body { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } + + .js-reveal { + opacity: 1; + transform: none; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..722d2f6 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,74 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - }; - - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; - - appendMessage(message, 'visitor'); - chatInput.value = ''; - - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); - } + const alerts = document.querySelectorAll('.js-autodismiss'); + alerts.forEach((alertEl) => { + window.setTimeout(() => { + if (window.bootstrap && bootstrap.Alert) { + bootstrap.Alert.getOrCreateInstance(alertEl).close(); + } + }, 3800); }); + + const companySwitcher = document.querySelector('.js-company-switcher'); + if (companySwitcher) { + companySwitcher.addEventListener('change', () => { + companySwitcher.form.submit(); + }); + } + + const reasonInput = document.querySelector('.js-reason-input'); + const reasonCount = document.querySelector('.js-reason-count'); + if (reasonInput && reasonCount) { + const syncCount = () => { + reasonCount.textContent = String(reasonInput.value.length); + }; + syncCount(); + reasonInput.addEventListener('input', syncCount); + } + + const dateInputs = document.querySelectorAll('.js-date-input'); + const daysPreview = document.querySelector('.js-days-preview'); + if (dateInputs.length === 2 && daysPreview) { + const syncDays = () => { + const [startInput, endInput] = dateInputs; + const start = startInput.value ? new Date(startInput.value) : null; + const end = endInput.value ? new Date(endInput.value) : null; + if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) { + daysPreview.textContent = '0'; + return; + } + const diffDays = Math.floor((end - start) / 86400000) + 1; + daysPreview.textContent = String(diffDays); + }; + syncDays(); + dateInputs.forEach((input) => input.addEventListener('input', syncDays)); + dateInputs.forEach((input) => input.addEventListener('change', syncDays)); + } + + const revealTargets = document.querySelectorAll('.hero-panel, .metric-card, .panel-card, .toast-stack .alert'); + revealTargets.forEach((element, index) => { + element.classList.add('js-reveal'); + element.style.setProperty('--reveal-delay', `${Math.min(index * 70, 360)}ms`); + }); + + if (prefersReducedMotion || !('IntersectionObserver' in window)) { + revealTargets.forEach((element) => element.classList.add('is-visible')); + return; + } + + const observer = new IntersectionObserver((entries, currentObserver) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) { + return; + } + entry.target.classList.add('is-visible'); + currentObserver.unobserve(entry.target); + }); + }, { + threshold: 0.14, + rootMargin: '0px 0px -28px 0px' + }); + + revealTargets.forEach((element) => observer.observe(element)); }); diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..75d737b --- /dev/null +++ b/healthz.php @@ -0,0 +1,20 @@ +query('SELECT 1'); + http_response_code(200); + echo json_encode([ + 'status' => 'ok', + 'app' => 'NexusHR', + 'time' => gmdate('c'), + ], JSON_UNESCAPED_SLASHES); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'time' => gmdate('c'), + ], JSON_UNESCAPED_SLASHES); +} diff --git a/includes/app.php b/includes/app.php new file mode 100644 index 0000000..2de08d8 --- /dev/null +++ b/includes/app.php @@ -0,0 +1,495 @@ + 'Nour Logistics', + 'atlas-clinic' => 'Atlas Clinic', +]; + +function textLength(string $value): int +{ + return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); +} + +function appProjectName(): string +{ + $name = $_SERVER['PROJECT_NAME'] ?? 'NexusHR'; + return trim((string)$name) !== '' ? (string)$name : 'NexusHR'; +} + +function appProjectDescription(): string +{ + $desc = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Multi-tenant HR workspace for attendance, leave, and employee operations.'; + return trim((string)$desc) !== '' ? (string)$desc : 'Multi-tenant HR workspace for attendance, leave, and employee operations.'; +} + +function ensureLeaveRequestTable(): void +{ + $sql = "CREATE TABLE IF NOT EXISTS leave_requests ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id VARCHAR(120) NOT NULL, + company_name VARCHAR(160) NOT NULL, + employee_name VARCHAR(160) NOT NULL, + employee_email VARCHAR(190) NOT NULL, + department VARCHAR(120) NOT NULL, + leave_type VARCHAR(60) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + days_requested INT UNSIGNED NOT NULL, + reason TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + rejection_reason TEXT DEFAULT NULL, + submitted_by VARCHAR(120) NOT NULL DEFAULT 'owner', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + reviewed_at TIMESTAMP NULL DEFAULT NULL, + INDEX idx_company_status (company_id, status), + INDEX idx_company_created (company_id, created_at), + INDEX idx_company_dates (company_id, start_date, end_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; + + db()->exec($sql); + + $countStmt = db()->query('SELECT COUNT(*) AS total FROM leave_requests'); + $count = (int)($countStmt->fetch()['total'] ?? 0); + if ($count > 0) { + return; + } + + $seed = db()->prepare( + 'INSERT INTO leave_requests + (company_id, company_name, employee_name, employee_email, department, leave_type, start_date, end_date, days_requested, reason, status, rejection_reason, submitted_by, reviewed_at) + VALUES + (:company_id, :company_name, :employee_name, :employee_email, :department, :leave_type, :start_date, :end_date, :days_requested, :reason, :status, :rejection_reason, :submitted_by, :reviewed_at)' + ); + + $records = [ + [ + 'company_id' => 'nour-logistics', + 'company_name' => 'Nour Logistics', + 'employee_name' => 'Ahmed Saleh', + 'employee_email' => 'ahmed@nour.example', + 'department' => 'Operations', + 'leave_type' => 'Annual leave', + 'start_date' => date('Y-m-d', strtotime('+3 days')), + 'end_date' => date('Y-m-d', strtotime('+5 days')), + 'days_requested' => 3, + 'reason' => 'Family travel booked during school break.', + 'status' => 'pending', + 'rejection_reason' => null, + 'submitted_by' => 'manager', + 'reviewed_at' => null, + ], + [ + 'company_id' => 'nour-logistics', + 'company_name' => 'Nour Logistics', + 'employee_name' => 'Sara Mahmoud', + 'employee_email' => 'sara@nour.example', + 'department' => 'Customer Success', + 'leave_type' => 'Sick leave', + 'start_date' => date('Y-m-d', strtotime('-2 days')), + 'end_date' => date('Y-m-d', strtotime('-1 days')), + 'days_requested' => 2, + 'reason' => 'Medical rest recommended by physician.', + 'status' => 'approved', + 'rejection_reason' => null, + 'submitted_by' => 'employee', + 'reviewed_at' => date('Y-m-d H:i:s', strtotime('-3 days')), + ], + [ + 'company_id' => 'atlas-clinic', + 'company_name' => 'Atlas Clinic', + 'employee_name' => 'Layla Hassan', + 'employee_email' => 'layla@atlas.example', + 'department' => 'Front Desk', + 'leave_type' => 'Emergency leave', + 'start_date' => date('Y-m-d', strtotime('+1 days')), + 'end_date' => date('Y-m-d', strtotime('+1 days')), + 'days_requested' => 1, + 'reason' => 'Urgent family matter requiring same-day absence.', + 'status' => 'rejected', + 'rejection_reason' => 'Critical staffing shortage on the selected day.', + 'submitted_by' => 'employee', + 'reviewed_at' => date('Y-m-d H:i:s', strtotime('-1 day')), + ], + ]; + + foreach ($records as $record) { + foreach ($record as $key => $value) { + $seed->bindValue(':' . $key, $value); + } + $seed->execute(); + } +} + +function currentCompanyId(): string +{ + if (isset($_GET['company']) && is_string($_GET['company']) && array_key_exists($_GET['company'], APP_DEMO_COMPANIES)) { + $_SESSION['company_id'] = $_GET['company']; + } + + $companyId = $_SESSION['company_id'] ?? 'nour-logistics'; + if (!array_key_exists($companyId, APP_DEMO_COMPANIES)) { + $companyId = 'nour-logistics'; + $_SESSION['company_id'] = $companyId; + } + + return $companyId; +} + +function currentCompanyName(): string +{ + return APP_DEMO_COMPANIES[currentCompanyId()] ?? 'Nour Logistics'; +} + +function currentCompanyFilterQuery(string $path, array $params = []): string +{ + $params = array_merge(['company' => currentCompanyId()], $params); + return $path . '?' . http_build_query($params); +} + +function formatDateLabel(?string $date): string +{ + if (!$date) { + return '—'; + } + + $time = strtotime($date); + return $time ? date('d M Y', $time) : '—'; +} + +function flash(string $type, string $message): void +{ + $_SESSION['flash'] = ['type' => $type, 'message' => $message]; +} + +function pullFlash(): ?array +{ + if (!isset($_SESSION['flash'])) { + return null; + } + + $flash = $_SESSION['flash']; + unset($_SESSION['flash']); + return is_array($flash) ? $flash : null; +} + +function redirectWithCompany(string $path, array $params = []): void +{ + header('Location: ' . currentCompanyFilterQuery($path, $params)); + exit; +} + +function leaveTypeOptions(): array +{ + return ['Annual leave', 'Sick leave', 'Emergency leave', 'Remote day', 'Unpaid leave']; +} + +function departmentOptions(): array +{ + return ['Operations', 'Customer Success', 'Finance', 'People Ops', 'Engineering', 'Front Desk']; +} + +function requestStatusBadgeClass(string $status): string +{ + return match ($status) { + 'approved' => 'text-bg-success', + 'rejected' => 'text-bg-danger', + default => 'text-bg-warning', + }; +} + +function tenantScopedRequestById(int $id): ?array +{ + $stmt = db()->prepare('SELECT * FROM leave_requests WHERE id = :id AND company_id = :company_id LIMIT 1'); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->bindValue(':company_id', currentCompanyId()); + $stmt->execute(); + $row = $stmt->fetch(); + return $row ?: null; +} + +function dashboardMetrics(): array +{ + $stmt = db()->prepare( + 'SELECT + COUNT(*) AS total_requests, + SUM(CASE WHEN status = "pending" THEN 1 ELSE 0 END) AS pending_requests, + SUM(CASE WHEN status = "approved" THEN days_requested ELSE 0 END) AS approved_days, + SUM(CASE WHEN status = "rejected" THEN 1 ELSE 0 END) AS rejected_requests + FROM leave_requests + WHERE company_id = :company_id' + ); + $stmt->bindValue(':company_id', currentCompanyId()); + $stmt->execute(); + $data = $stmt->fetch() ?: []; + + return [ + 'total_requests' => (int)($data['total_requests'] ?? 0), + 'pending_requests' => (int)($data['pending_requests'] ?? 0), + 'approved_days' => (int)($data['approved_days'] ?? 0), + 'rejected_requests' => (int)($data['rejected_requests'] ?? 0), + ]; +} + +function recentLeaveRequests(int $limit = 6): array +{ + $stmt = db()->prepare('SELECT * FROM leave_requests WHERE company_id = :company_id ORDER BY created_at DESC LIMIT :limit'); + $stmt->bindValue(':company_id', currentCompanyId()); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); +} + +function pendingLeaveRequests(int $limit = 5): array +{ + $stmt = db()->prepare('SELECT * FROM leave_requests WHERE company_id = :company_id AND status = :status ORDER BY start_date ASC LIMIT :limit'); + $stmt->bindValue(':company_id', currentCompanyId()); + $stmt->bindValue(':status', 'pending'); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); +} + +function leaveRequestTrend(): array +{ + $stmt = db()->prepare( + 'SELECT DATE_FORMAT(created_at, "%b %Y") AS month_label, COUNT(*) AS total + FROM leave_requests + WHERE company_id = :company_id + GROUP BY YEAR(created_at), MONTH(created_at) + ORDER BY YEAR(created_at), MONTH(created_at)' + ); + $stmt->bindValue(':company_id', currentCompanyId()); + $stmt->execute(); + return $stmt->fetchAll(); +} + +function filteredLeaveRequests(?string $status = null): array +{ + if ($status && in_array($status, ['pending', 'approved', 'rejected'], true)) { + $stmt = db()->prepare('SELECT * FROM leave_requests WHERE company_id = :company_id AND status = :status ORDER BY start_date DESC, created_at DESC'); + $stmt->bindValue(':status', $status); + } else { + $stmt = db()->prepare('SELECT * FROM leave_requests WHERE company_id = :company_id ORDER BY start_date DESC, created_at DESC'); + } + + $stmt->bindValue(':company_id', currentCompanyId()); + $stmt->execute(); + return $stmt->fetchAll(); +} + +function createLeaveRequest(array $input): array +{ + $employeeName = trim((string)($input['employee_name'] ?? '')); + $employeeEmail = trim((string)($input['employee_email'] ?? '')); + $department = trim((string)($input['department'] ?? '')); + $leaveType = trim((string)($input['leave_type'] ?? '')); + $startDate = trim((string)($input['start_date'] ?? '')); + $endDate = trim((string)($input['end_date'] ?? '')); + $reason = trim((string)($input['reason'] ?? '')); + + $errors = []; + + if ($employeeName === '' || textLength($employeeName) < 3) { + $errors['employee_name'] = 'Enter a full employee name.'; + } + if (!filter_var($employeeEmail, FILTER_VALIDATE_EMAIL)) { + $errors['employee_email'] = 'Enter a valid work email.'; + } + if (!in_array($department, departmentOptions(), true)) { + $errors['department'] = 'Choose a valid department.'; + } + if (!in_array($leaveType, leaveTypeOptions(), true)) { + $errors['leave_type'] = 'Choose a valid leave type.'; + } + if (!$startDate || !$endDate) { + $errors['dates'] = 'Start and end dates are required.'; + } + + $startTs = strtotime($startDate); + $endTs = strtotime($endDate); + if (!$startTs || !$endTs || $endTs < $startTs) { + $errors['dates'] = 'End date must be on or after the start date.'; + } + + if ($reason === '' || textLength($reason) < 10) { + $errors['reason'] = 'Provide a short business reason (10+ characters).'; + } + + $daysRequested = 0; + if ($startTs && $endTs && $endTs >= $startTs) { + $daysRequested = (int) floor(($endTs - $startTs) / 86400) + 1; + } + + if ($daysRequested < 1) { + $errors['days_requested'] = 'Requested leave must be at least one day.'; + } + + if ($errors) { + return ['success' => false, 'errors' => $errors]; + } + + $stmt = db()->prepare( + 'INSERT INTO leave_requests + (company_id, company_name, employee_name, employee_email, department, leave_type, start_date, end_date, days_requested, reason, status, submitted_by) + VALUES + (:company_id, :company_name, :employee_name, :employee_email, :department, :leave_type, :start_date, :end_date, :days_requested, :reason, :status, :submitted_by)' + ); + + $stmt->bindValue(':company_id', currentCompanyId()); + $stmt->bindValue(':company_name', currentCompanyName()); + $stmt->bindValue(':employee_name', $employeeName); + $stmt->bindValue(':employee_email', $employeeEmail); + $stmt->bindValue(':department', $department); + $stmt->bindValue(':leave_type', $leaveType); + $stmt->bindValue(':start_date', $startDate); + $stmt->bindValue(':end_date', $endDate); + $stmt->bindValue(':days_requested', $daysRequested, PDO::PARAM_INT); + $stmt->bindValue(':reason', $reason); + $stmt->bindValue(':status', 'pending'); + $stmt->bindValue(':submitted_by', 'owner'); + $stmt->execute(); + + return ['success' => true, 'id' => (int)db()->lastInsertId()]; +} + +function reviewLeaveRequest(int $id, string $action, string $rejectionReason = ''): bool +{ + $request = tenantScopedRequestById($id); + if (!$request || $request['status'] !== 'pending') { + return false; + } + + $status = $action === 'approve' ? 'approved' : 'rejected'; + if ($status === 'rejected' && trim($rejectionReason) === '') { + return false; + } + + $stmt = db()->prepare( + 'UPDATE leave_requests + SET status = :status, rejection_reason = :rejection_reason, reviewed_at = NOW() + WHERE id = :id AND company_id = :company_id AND status = :current_status' + ); + $stmt->bindValue(':status', $status); + $stmt->bindValue(':rejection_reason', $status === 'rejected' ? trim($rejectionReason) : null); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->bindValue(':company_id', currentCompanyId()); + $stmt->bindValue(':current_status', 'pending'); + $stmt->execute(); + + return $stmt->rowCount() > 0; +} + +function renderPageStart(string $pageTitle, string $metaDescription, string $activeNav = 'dashboard'): void +{ + $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ''; + $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; + $projectName = appProjectName(); + $fullTitle = htmlspecialchars($pageTitle . ' · ' . $projectName); + $resolvedDescription = htmlspecialchars($metaDescription !== '' ? $metaDescription : appProjectDescription()); + $flash = pullFlash(); + $currentCompanyId = currentCompanyId(); + ?> + + +
+ + +This first delivery focuses on one meaningful end-to-end workflow: create leave requests, review them safely inside the active company, and monitor approval health from a single SaaS-style dashboard.
+Scoped to current company only.
+Needs manager action.
+Deductible leave usage.
+With auditable reason.
+= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-This page will update automatically as the plan is implemented.
-Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
Create the first request to turn this into a live approval queue.
+ Create request +| Employee | +Type | +Dates | +Status | +Detail | +
|---|---|---|---|---|
|
+ = htmlspecialchars($request['employee_name']) ?>
+ = htmlspecialchars($request['department']) ?>
+ |
+ = htmlspecialchars($request['leave_type']) ?> | += htmlspecialchars(formatDateLabel($request['start_date'])) ?> → = htmlspecialchars(formatDateLabel($request['end_date'])) ?> | += htmlspecialchars(ucfirst($request['status'])) ?> | +Open | +
No pending requests for this tenant.
+Recent activity will appear after the first submission.
+Request #= (int)$request['id'] ?> for = htmlspecialchars($request['company_name']) ?>.
+= nl2br(htmlspecialchars($request['reason'])) ?>
+= nl2br(htmlspecialchars($request['rejection_reason'])) ?>
+This request is locked after status resolution for a cleaner audit trail.
+company_id.Filtered by active company context: = htmlspecialchars(currentCompanyName()) ?>.
+Try another filter or add the first leave request for this company.
+ Create request +| Employee | +Department | +Window | +Days | +Status | +Action | +
|---|---|---|---|---|---|
|
+ = htmlspecialchars($request['employee_name']) ?>
+ = htmlspecialchars($request['employee_email']) ?>
+ |
+ = htmlspecialchars($request['department']) ?> | +
+ = htmlspecialchars(formatDateLabel($request['start_date'])) ?>
+ to = htmlspecialchars(formatDateLabel($request['end_date'])) ?>
+ |
+ = (int)$request['days_requested'] ?> | += htmlspecialchars(ucfirst($request['status'])) ?> | +View detail | +
Every request is stored with company_id and only appears inside the active tenant workspace.