mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
2562 lines
176 KiB
HTML
2562 lines
176 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="{{ current_language_code or 'en' }}" dir="{{ 'rtl' if is_rtl else 'ltr' }}">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<meta name="description" content="Professional time tracking and project management">
|
|
<meta name="theme-color" content="#4F46E5">
|
|
|
|
<!-- Open Graph / Social Media -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:title" content="{% block og_title %}{{ app_name }} - Professional Time Tracking{% endblock %}">
|
|
<meta property="og:description" content="{% block og_description %}Professional time tracking and project management application{% endblock %}">
|
|
<meta property="og:image" content="{% block og_image %}{{ url_for('static', filename='images/og-image.png', _external=True) }}{% endblock %}">
|
|
<meta property="og:url" content="{{ request.url }}">
|
|
<meta property="og:site_name" content="{{ app_name }}">
|
|
|
|
<!-- Twitter Card -->
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="{% block twitter_title %}{{ app_name }} - Professional Time Tracking{% endblock %}">
|
|
<meta name="twitter:description" content="{% block twitter_description %}Professional time tracking and project management application{% endblock %}">
|
|
<meta name="twitter:image" content="{% block twitter_image %}{{ url_for('static', filename='images/og-image.png', _external=True) }}{% endblock %}">
|
|
|
|
<!-- Favicon -->
|
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/timetracker-logo.svg') }}">
|
|
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='images/favicon.ico') }}">
|
|
<link rel="alternate icon" href="{{ url_for('static', filename='images/favicon.ico') }}">
|
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
|
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='images/android-chrome-192x192.png') }}">
|
|
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='images/android-chrome-512x512.png') }}">
|
|
<!--
|
|
Telemetry Privacy Protection:
|
|
Add the CSS class 'ph-no-capture' to any element containing sensitive data
|
|
to prevent capture in opt-in telemetry instrumentation.
|
|
|
|
Examples:
|
|
- Project names: <span class="ph-no-capture">Project ABC</span>
|
|
- Client names: <div class="ph-no-capture">Client XYZ</div>
|
|
- Time entry notes: <textarea class="ph-no-capture" name="notes"></textarea>
|
|
- User emails: <span class="ph-no-capture">user@example.com</span>
|
|
|
|
Telemetry instrumentation should avoid these elements by design.
|
|
-->
|
|
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
|
<meta name="vapid-public-key" content="{{ config.get('VAPID_PUBLIC_KEY', '') }}">
|
|
<script src="{{ url_for('static', filename='pwa-enhancements.js') }}"></script>
|
|
<!-- Inter font is loaded via CSS @import in app/static/src/input.css -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='dist/output.css') }}?v={{ app_version }}-toastfix1">
|
|
<!-- Font Awesome -->
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='form-bridge.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='form-validation.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='data-tables-enhanced.css') }}">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
|
|
<link id="flatpickr-dark-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/themes/dark.css" media="none">
|
|
<style>
|
|
/* RTL Support */
|
|
html[dir="rtl"] {
|
|
direction: rtl;
|
|
}
|
|
html[dir="rtl"] .ml-auto { margin-left: 0; margin-right: auto; }
|
|
html[dir="rtl"] .mr-auto { margin-right: 0; margin-left: auto; }
|
|
html[dir="rtl"] .text-left { text-align: right; }
|
|
html[dir="rtl"] .text-right { text-align: left; }
|
|
html[dir="rtl"] #sidebar { left: auto; right: 0; }
|
|
html[dir="rtl"] #mainContent { margin-left: 0; margin-right: 16rem; }
|
|
@media (max-width: 767px) {
|
|
html[dir="rtl"] #mainContent { margin-right: 0; }
|
|
}
|
|
|
|
/* Minimal styles to properly align enhanced search UI */
|
|
.search-enhanced .search-input-wrapper { position: relative; }
|
|
.search-enhanced .search-icon { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: #A0AEC0; pointer-events: none; }
|
|
.dark .search-enhanced .search-icon { color: #718096; }
|
|
.search-enhanced .search-actions { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); display: flex; align-items: center; gap: 0.25rem; }
|
|
.search-enhanced .search-actions .search-kbd { font-size: 11px; line-height: 1; padding: 2px 6px; border: 1px solid #E2E8F0; border-radius: 4px; color: #A0AEC0; }
|
|
.dark .search-enhanced .search-actions .search-kbd { border-color: #4A5568; color: #718096; }
|
|
/* Autocomplete dropdown: overlay below input */
|
|
.search-enhanced { position: relative; }
|
|
.search-enhanced .search-autocomplete { position: absolute; top: calc(100% + 4px); left: 0; right: 0; display: none; z-index: 50; background: #FFFFFF; color: #2D3748; border: 1px solid #E2E8F0; border-radius: 0.5rem; box-shadow: 0 8px 24px rgba(0,0,0,0.12); max-height: 22rem; overflow-y: auto; }
|
|
.dark .search-enhanced .search-autocomplete { background: #2D3748; color: #E2E8F0; border-color: #4A5568; }
|
|
.search-enhanced .search-autocomplete.show { display: block; }
|
|
.search-enhanced .search-section { padding: 0.5rem 0.5rem; }
|
|
.search-enhanced .search-section-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: .06em; margin: 0.25rem 0.5rem; color: #A0AEC0; }
|
|
.dark .search-enhanced .search-section-title { color: #718096; }
|
|
.search-enhanced .search-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.75rem; text-decoration: none; color: inherit; }
|
|
.search-enhanced .search-item:hover, .search-enhanced .search-item.keyboard-focus { background: #F7F9FB; }
|
|
.dark .search-enhanced .search-item:hover, .dark .search-enhanced .search-item.keyboard-focus { background: #1A202C; }
|
|
.search-enhanced .search-item-title { font-weight: 500; }
|
|
/* Sidebar collapsed helpers */
|
|
.sidebar-collapsed .sidebar-label { display: none; }
|
|
.sidebar-collapsed .sidebar-header-title { display: none; }
|
|
.sidebar-collapsed #sidebar { width: 4.5rem !important; overflow-x: hidden; }
|
|
.sidebar-collapsed #sidebar ul[id$="Dropdown"] { display: none !important; }
|
|
.sidebar-collapsed #sidebar .sidebar-nav-item {
|
|
justify-content: center;
|
|
padding-left: 0.625rem;
|
|
padding-right: 0.625rem;
|
|
}
|
|
.sidebar-collapsed #sidebar .sidebar-nav-item > i:first-child {
|
|
margin-right: 0 !important;
|
|
}
|
|
.sidebar-collapsed #sidebar .sidebar-nav-item .fa-chevron-down {
|
|
display: none;
|
|
}
|
|
/* App version in sidebar footer: not using .sidebar-label so it stays visible when collapsed */
|
|
.sidebar-collapsed #sidebar .sidebar-version-row {
|
|
flex-direction: column;
|
|
gap: 0.125rem;
|
|
padding-left: 0.25rem;
|
|
padding-right: 0.25rem;
|
|
}
|
|
.sidebar-collapsed #sidebar .sidebar-version-text {
|
|
margin-left: 0 !important;
|
|
font-size: 0.625rem;
|
|
line-height: 1.15;
|
|
text-align: center;
|
|
max-width: 100%;
|
|
}
|
|
#sidebarFlyout {
|
|
min-width: 14rem;
|
|
max-width: 18rem;
|
|
max-height: min(70vh, 32rem);
|
|
overflow-y: auto;
|
|
backdrop-filter: blur(14px);
|
|
-webkit-backdrop-filter: blur(14px);
|
|
}
|
|
#sidebarFlyout .flyout-title {
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: #718096;
|
|
padding: 0.375rem 0.75rem 0.5rem;
|
|
}
|
|
.dark #sidebarFlyout .flyout-title {
|
|
color: #A0AEC0;
|
|
}
|
|
#sidebarFlyout .flyout-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
width: 100%;
|
|
padding: 0.65rem 0.75rem;
|
|
border-radius: 0.875rem;
|
|
color: inherit;
|
|
text-decoration: none;
|
|
transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
|
}
|
|
#sidebarFlyout .flyout-link:hover {
|
|
background: #F7F9FB;
|
|
transform: translateX(2px);
|
|
}
|
|
.dark #sidebarFlyout .flyout-link:hover {
|
|
background: #1A202C;
|
|
}
|
|
#sidebarFlyout .flyout-link i {
|
|
width: 1rem;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
}
|
|
#sidebarFlyout .flyout-link.is-active {
|
|
background: rgba(74, 144, 226, 0.12);
|
|
color: #4A90E2;
|
|
font-weight: 600;
|
|
}
|
|
/* Multi-line sidebar labels: left-align wrapped text, allow flex shrink for wrapping, keep icons/chevron centered */
|
|
#sidebar .sidebar-nav-item span.sidebar-label { text-align: start; min-width: 0; flex: 1 1 0%; }
|
|
#sidebar .sidebar-nav-item > i { flex-shrink: 0; }
|
|
/* Shared bulk menu */
|
|
.bulk-menu { z-index: 50; max-height: 16rem; overflow-y: auto; }
|
|
/* Layout fixes */
|
|
#mainContent {
|
|
display: flex !important;
|
|
flex-direction: column !important;
|
|
overflow-x: hidden;
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
#mainContentAnchor {
|
|
flex: 1 0 auto;
|
|
min-height: 0;
|
|
min-width: 0;
|
|
width: 100%;
|
|
overflow-x: hidden;
|
|
}
|
|
/* Ensure body and html don't cause horizontal overflow */
|
|
html, body { overflow-x: hidden; }
|
|
#appShell { overflow-x: hidden; width: 100%; min-width: 0; }
|
|
/* Header toolbar: allow horizontal scroll without visible scrollbar (narrow phones) */
|
|
.header-toolbar-scroll {
|
|
-ms-overflow-style: none;
|
|
scrollbar-width: none;
|
|
}
|
|
.header-toolbar-scroll::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
/* Issue #573: compact bottom nav on narrow phones without requiring Tailwind arbitrary breakpoint in build */
|
|
@media (max-width: 400px) {
|
|
.mobile-bottom-nav {
|
|
min-height: 3rem;
|
|
}
|
|
.mobile-bottom-nav .bottom-nav-text {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border-width: 0;
|
|
}
|
|
.mobile-bottom-nav a.relative,
|
|
.mobile-bottom-nav button.relative {
|
|
padding-top: 0.25rem;
|
|
padding-bottom: 0.25rem;
|
|
}
|
|
}
|
|
|
|
/* Custom scrollbar styling for sidebar */
|
|
#sidebar {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
|
}
|
|
.dark #sidebar {
|
|
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
|
|
}
|
|
/* Webkit scrollbar styling (Chrome, Edge, Safari) */
|
|
#sidebar::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
#sidebar::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
#sidebar::-webkit-scrollbar-thumb {
|
|
background-color: rgba(156, 163, 175, 0.5);
|
|
border-radius: 4px;
|
|
border: 2px solid transparent;
|
|
background-clip: padding-box;
|
|
}
|
|
#sidebar::-webkit-scrollbar-thumb:hover {
|
|
background-color: rgba(156, 163, 175, 0.7);
|
|
}
|
|
.dark #sidebar::-webkit-scrollbar-thumb {
|
|
background-color: rgba(156, 163, 175, 0.3);
|
|
}
|
|
.dark #sidebar::-webkit-scrollbar-thumb:hover {
|
|
background-color: rgba(156, 163, 175, 0.5);
|
|
}
|
|
/* Bottom-right floating hub: one deterministic dock, one actions menu. */
|
|
#fabDock {
|
|
--fab-size: 3.5rem;
|
|
--fab-gap: 0.75rem;
|
|
--fab-edge: 1.25rem;
|
|
--fab-menu-gap: 0.75rem;
|
|
position: fixed;
|
|
right: var(--fab-edge);
|
|
bottom: var(--fab-edge);
|
|
z-index: 65;
|
|
display: flex;
|
|
flex-direction: column-reverse;
|
|
align-items: flex-end;
|
|
gap: var(--fab-gap);
|
|
pointer-events: none;
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
}
|
|
#fabDock.fab-dock--admin {
|
|
bottom: 8rem;
|
|
}
|
|
#fabDock > * {
|
|
pointer-events: auto;
|
|
}
|
|
#fabDock.fab-dock--menu-open > :not(#unifiedActionsRoot) {
|
|
opacity: 0.35;
|
|
pointer-events: none;
|
|
transition: opacity 120ms ease;
|
|
}
|
|
#unifiedActionsRoot,
|
|
#persistentChatWidget,
|
|
#aiHelperRoot {
|
|
margin: 0;
|
|
flex-shrink: 0;
|
|
align-self: flex-end;
|
|
width: var(--fab-size);
|
|
min-height: var(--fab-size);
|
|
}
|
|
html[dir="rtl"] #unifiedActionsRoot,
|
|
html[dir="rtl"] #persistentChatWidget,
|
|
html[dir="rtl"] #aiHelperRoot {
|
|
align-self: flex-start;
|
|
}
|
|
#unifiedActionsFab,
|
|
#chatWidgetToggle,
|
|
#aiHelperFab {
|
|
width: var(--fab-size);
|
|
height: var(--fab-size);
|
|
}
|
|
#unifiedActionsRoot {
|
|
position: relative;
|
|
z-index: 80;
|
|
}
|
|
#unifiedActionsRoot.is-open {
|
|
z-index: 100;
|
|
}
|
|
#unifiedActionsMenu {
|
|
position: absolute;
|
|
right: 0;
|
|
bottom: calc(100% + var(--fab-menu-gap));
|
|
min-width: 14rem;
|
|
z-index: 101;
|
|
transform-origin: bottom right;
|
|
}
|
|
html[dir="rtl"] #unifiedActionsMenu {
|
|
right: auto;
|
|
left: 0;
|
|
transform-origin: bottom left;
|
|
}
|
|
#persistentChatWidget {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
}
|
|
#chatWidgetPanel {
|
|
position: fixed;
|
|
right: var(--fab-edge);
|
|
bottom: var(--fab-edge);
|
|
width: min(24rem, calc(100vw - (var(--fab-edge) * 2)));
|
|
height: min(600px, calc(100vh - var(--fab-edge) - 2rem));
|
|
z-index: 85;
|
|
}
|
|
body.fab-dock-admin #chatWidgetPanel {
|
|
bottom: 8rem;
|
|
}
|
|
html[dir="rtl"] #persistentChatWidget {
|
|
align-items: flex-start;
|
|
}
|
|
html[dir="rtl"] #chatWidgetPanel {
|
|
right: auto;
|
|
left: var(--fab-edge);
|
|
}
|
|
html[dir="rtl"] #fabDock {
|
|
right: auto;
|
|
left: var(--fab-edge);
|
|
align-items: flex-start;
|
|
}
|
|
@media (max-width: 767px) {
|
|
#fabDock {
|
|
--fab-edge: 1rem;
|
|
bottom: 5rem;
|
|
}
|
|
#fabDock.fab-dock--admin {
|
|
bottom: 11rem;
|
|
}
|
|
#unifiedActionsMenu {
|
|
min-width: min(14rem, calc(100vw - 2rem));
|
|
}
|
|
#chatWidgetPanel {
|
|
bottom: 5rem;
|
|
}
|
|
body.fab-dock-admin #chatWidgetPanel {
|
|
bottom: 11rem;
|
|
}
|
|
}
|
|
</style>
|
|
<script>
|
|
// Theme init (unchanged)
|
|
(function() {
|
|
var stored = localStorage.getItem('color-theme');
|
|
var userPref = {% if current_user.is_authenticated %}{% if current_user.theme_preference %}'{{ current_user.theme_preference }}'{% else %}'system'{% endif %}{% else %}null{% endif %};
|
|
var effective = userPref || stored || 'system';
|
|
if (effective === 'system' || !effective) {
|
|
effective = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
}
|
|
if (effective === 'dark') {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
if (userPref) localStorage.setItem('color-theme', userPref === 'system' || !userPref ? 'system' : userPref);
|
|
})();
|
|
var fpDark = document.getElementById('flatpickr-dark-theme');
|
|
if (fpDark) fpDark.media = document.documentElement.classList.contains('dark') ? 'all' : 'none';
|
|
</script>
|
|
|
|
<!-- i18n translations for JavaScript (use tojson to avoid syntax errors from quotes/ampersands in translations) -->
|
|
<script>
|
|
window.i18n = {
|
|
toast: {
|
|
success: {{ _("Success")|tojson }},
|
|
error: {{ _("Error")|tojson }},
|
|
warning: {{ _("Warning")|tojson }},
|
|
info: {{ _("Information")|tojson }}
|
|
},
|
|
common: {
|
|
loading: {{ _("Loading...")|tojson }},
|
|
saving: {{ _("Saving...")|tojson }},
|
|
deleting: {{ _("Deleting...")|tojson }},
|
|
cancel: {{ _("Cancel")|tojson }},
|
|
confirm: {{ _("Confirm")|tojson }},
|
|
close: {{ _("Close")|tojson }},
|
|
save: {{ _("Save")|tojson }},
|
|
delete: {{ _("Delete")|tojson }},
|
|
edit: {{ _("Edit")|tojson }},
|
|
add: {{ _("Add")|tojson }},
|
|
remove: {{ _("Remove")|tojson }},
|
|
yes: {{ _("Yes")|tojson }},
|
|
no: {{ _("No")|tojson }},
|
|
ok: {{ _("OK")|tojson }}
|
|
},
|
|
messages: {
|
|
confirmDelete: {{ _("Are you sure you want to delete this?")|tojson }},
|
|
unsavedChanges: {{ _("You have unsaved changes. Are you sure you want to leave?")|tojson }},
|
|
operationFailed: {{ _("Operation failed")|tojson }},
|
|
operationSuccess: {{ _("Operation completed successfully")|tojson }},
|
|
noItemsSelected: {{ _("No items selected")|tojson }},
|
|
invalidInput: {{ _("Invalid input")|tojson }},
|
|
requiredField: {{ _("This field is required")|tojson }},
|
|
noActiveTimer: {{ _("No active timer")|tojson }},
|
|
timerStopped: {{ _("Timer stopped")|tojson }},
|
|
timerStopFailed: {{ _("Failed to stop timer")|tojson }},
|
|
errorStoppingTimer: {{ _("Error stopping timer")|tojson }},
|
|
noFormToSave: {{ _("No form to save")|tojson }},
|
|
noTimerFound: {{ _("No timer found")|tojson }},
|
|
timerStoppedInactivity: {{ _("Timer stopped due to inactivity")|tojson }},
|
|
selectProjectOrClient: {{ _("Please select either a project or a client")|tojson }},
|
|
endTimeAfterStartTime: {{ _("End time must be after start time")|tojson }}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<!-- User date/time format preferences for JavaScript (resolved: user override or system default) -->
|
|
<script>
|
|
window.userPrefs = {
|
|
dateFormat: {{ resolved_date_format_key|tojson }},
|
|
timeFormat: {{ resolved_time_format_key|tojson }},
|
|
weekStartDay: {{ resolved_week_start_day|tojson }}
|
|
};
|
|
/**
|
|
* Format a JS Date according to the user's preferred date format.
|
|
* @param {Date} date
|
|
* @returns {string}
|
|
*/
|
|
window.formatUserDate = function(date) {
|
|
if (!date || isNaN(date)) return '';
|
|
var d = date.getDate().toString().padStart(2, '0');
|
|
var m = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
var y = date.getFullYear();
|
|
switch (window.userPrefs.dateFormat) {
|
|
case 'DD/MM/YYYY': return d + '/' + m + '/' + y;
|
|
case 'DD.MM.YYYY': return d + '.' + m + '.' + y;
|
|
case 'MM/DD/YYYY': return m + '/' + d + '/' + y;
|
|
default: return y + '-' + m + '-' + d;
|
|
}
|
|
};
|
|
/**
|
|
* Format a JS Date's time portion according to the user's preferred time format.
|
|
* @param {Date} date
|
|
* @returns {string}
|
|
*/
|
|
window.formatUserTime = function(date) {
|
|
if (!date || isNaN(date)) return '';
|
|
if (window.userPrefs.timeFormat === '12h') {
|
|
var h = date.getHours();
|
|
var ampm = h >= 12 ? 'PM' : 'AM';
|
|
h = h % 12 || 12;
|
|
return h.toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0') + ' ' + ampm;
|
|
}
|
|
return date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0');
|
|
};
|
|
/**
|
|
* Format a JS Date as date + time according to user preferences.
|
|
* @param {Date} date
|
|
* @returns {string}
|
|
*/
|
|
window.formatUserDateTime = function(date) {
|
|
if (!date || isNaN(date)) return '';
|
|
return window.formatUserDate(date) + ' ' + window.formatUserTime(date);
|
|
};
|
|
</script>
|
|
|
|
{% block extra_css %}{% endblock %}
|
|
</head>
|
|
<body class="font-sans antialiased bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark{% if is_admin_user %} fab-dock-admin{% endif %}">
|
|
<script>
|
|
// Sidebar collapsed ASAP
|
|
(function(){ try { if (localStorage.getItem('sidebar-collapsed') === 'true') { document.documentElement.classList.add('sidebar-collapsed'); } } catch(_) {} })();
|
|
</script>
|
|
<a href="#mainContentAnchor" class="sr-only focus:not-sr-only focus-ring absolute left-2 top-2 z-[1000] px-3 py-2 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded">{{ _('Skip to content') }}</a>
|
|
<div id="appShell" class="flex min-h-screen">
|
|
<!-- Sidebar -->
|
|
<aside id="sidebar" class="sidebar-scrollbar-hover w-64 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark p-4 flex-col hidden md:flex transition-all duration-200 ease-in-out fixed top-0 left-0 h-screen overflow-y-auto z-10" aria-label="{{ _('Main navigation') }}">
|
|
<div class="flex items-center mb-8">
|
|
<img src="{{ url_for('static', filename='images/timetracker-logo.svg') }}" alt="Logo" class="h-10 w-10 mr-2">
|
|
<h1 class="text-2xl font-bold text-primary sidebar-header-title"><a href="{{ url_for('main.dashboard') }}" class="no-underline">TimeTracker</a></h1>
|
|
</div>
|
|
<nav class="flex-1">
|
|
{% set ep = request.endpoint or '' %}
|
|
{% set work_open = (ep.startswith('projects.') or ep.startswith('tasks.') or ep.startswith('issues.') or ep.startswith('timer.') or ep.startswith('time_approvals.') or ep.startswith('kanban.') or ep.startswith('weekly_goals.') or ep.startswith('project_templates.') or ep.startswith('gantt.') or ep.startswith('workforce.')) and ep != 'timer.manual_entry' and ep != 'timer.time_entries_overview' %}
|
|
{% set calendar_open = ep.startswith('calendar.') %}
|
|
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('recurring_invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('mileage.') or ep.startswith('per_diem.') or ep.startswith('budget_alerts.') or ep.startswith('invoice_approvals.') or ep.startswith('payment_gateways.') or ep.startswith('scheduled_reports.') or ep.startswith('custom_reports.') %}
|
|
{% set crm_open = ep.startswith('clients.') or ep.startswith('quotes.') or ep.startswith('contacts.') or ep.startswith('deals.') or ep.startswith('leads.') %}
|
|
{% set inventory_open = ep.startswith('inventory.') %}
|
|
{% set analytics_open = ep.startswith('analytics.') %}
|
|
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') or ep.startswith('integrations.') or ep == 'integrations.list_integrations' or ep == 'integrations.manage_integration' or ep == 'integrations.view_integration' or ep == 'integrations.connect_integration' or ep == 'integrations.caldav_setup' %}
|
|
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) or ep.startswith('time_entry_templates.') or ep.startswith('audit_logs.') or ep.startswith('webhooks.') or ep.startswith('custom_field_definitions.') or ep.startswith('link_templates.') %}
|
|
{% set admin_user_mgmt_open = ep == 'admin.list_users' or ep.startswith('permissions.') %}
|
|
{% set admin_settings_open = ep == 'admin.settings' or ep == 'admin.email_support' or ep == 'admin.manage_modules' or ep.startswith('admin.') and ('email_template' in ep or 'email-templates' in request.path) or ep == 'admin.oidc_debug' %}
|
|
{% set admin_security_open = ep == 'admin.api_tokens' or ep.startswith('webhooks.') or ep.startswith('audit_logs.') %}
|
|
{% set admin_data_open = ep == 'expense_categories.list_categories' or ep == 'per_diem.list_rates' or ep.startswith('time_entry_templates.') or ep.startswith('custom_field_definitions.') or ep.startswith('link_templates.') %}
|
|
{% set admin_maintenance_open = ep == 'admin.system_info' or ep == 'admin.backups_management' or ep == 'admin.telemetry_dashboard' %}
|
|
{% set pdf_open = ep == 'admin.pdf_layout' or ep == 'admin.quote_pdf_layout' %}
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider sidebar-label">{{ _('Navigation') }}</span>
|
|
<button
|
|
type="button"
|
|
class="p-1.5 rounded hover:bg-background-light dark:hover:bg-background-dark"
|
|
onclick="window.openCommandPalette?.()"
|
|
aria-label="{{ _('Open command palette') }}"
|
|
title="{{ _('Open command palette') }}"
|
|
>
|
|
<span class="inline-flex items-center gap-1.5 text-xs text-text-muted-light dark:text-text-muted-dark">
|
|
<i class="fas fa-terminal"></i>
|
|
<span class="hidden sidebar-label sm:inline">{{ _('Commands') }}</span>
|
|
<span class="inline-flex items-center gap-1">
|
|
<kbd class="px-1.5 py-0.5 bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark rounded text-[11px]">Ctrl</kbd>
|
|
<kbd class="px-1.5 py-0.5 bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark rounded text-[11px]">K</kbd>
|
|
</span>
|
|
</span>
|
|
</button>
|
|
<button id="sidebarCollapseBtn" class="p-1.5 rounded hover:bg-background-light dark:hover:bg-background-dark" aria-label="{{ _('Toggle sidebar') }}" title="{{ _('Toggle sidebar') }}">
|
|
<i id="sidebarCollapseIcon" class="fas fa-angles-left"></i>
|
|
</button>
|
|
</div>
|
|
<ul>
|
|
<li>
|
|
<a href="{{ url_for('main.dashboard') }}" class="sidebar-nav-item flex items-center p-2 rounded-lg border-l-4 {% if ep == 'main.dashboard' %}border-primary bg-primary/5 dark:bg-primary/10 text-primary font-semibold{% else %}border-transparent text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" title="{{ _('Dashboard') }}">
|
|
<i class="fas fa-tachometer-alt w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Dashboard') }}</span>
|
|
</a>
|
|
</li>
|
|
<li class="mt-1">
|
|
<a href="{{ url_for('timer.manual_entry') }}" class="sidebar-nav-item flex items-center p-2 rounded-lg border-l-4 {% if ep.startswith('timer.') and ep != 'timer.time_entries_overview' %}border-primary bg-primary/5 dark:bg-primary/10 text-primary font-semibold{% else %}border-transparent text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" title="{{ _('Timer') }}">
|
|
<i class="fas fa-stopwatch w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Timer') }}</span>
|
|
</a>
|
|
</li>
|
|
<li class="mt-1">
|
|
<a href="{{ url_for('timer.time_entries_overview') }}" class="sidebar-nav-item flex items-center p-2 rounded-lg border-l-4 {% if ep == 'timer.time_entries_overview' %}border-primary bg-primary/5 dark:bg-primary/10 text-primary font-semibold{% else %}border-transparent text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" title="{{ _('Time entries') }}">
|
|
<i class="fas fa-list-alt w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Time entries') }}</span>
|
|
</a>
|
|
</li>
|
|
{% if is_module_enabled('reports') %}
|
|
<li class="mt-1">
|
|
<a href="{{ url_for('reports.reports') }}" class="sidebar-nav-item flex items-center p-2 rounded-lg border-l-4 {% if ep.startswith('reports.') and not ep.startswith('scheduled_reports.') and not ep.startswith('custom_reports.') %}border-primary bg-primary/5 dark:bg-primary/10 text-primary font-semibold{% else %}border-transparent text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" title="{{ _('Reports') }}">
|
|
<i class="fas fa-chart-line w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Reports') }}</span>
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('calendar') %}
|
|
<li class="mt-3 pt-3 border-t border-border-light dark:border-border-dark">
|
|
<button data-dropdown="calendarDropdown" class="sidebar-nav-item w-full flex items-center p-2 rounded-lg border-l-4 {% if calendar_open %}border-primary bg-primary/5 dark:bg-primary/10 text-primary font-semibold{% else %}border-transparent text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" title="{{ _('Calendar') }}">
|
|
<i class="fas fa-calendar-alt w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Calendar') }}</span>
|
|
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
|
|
</button>
|
|
<ul id="calendarDropdown" class="{% if not calendar_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% set nav_active_calendar = ep.startswith('calendar.view_calendar') %}
|
|
{% set nav_active_calendar_integrations = ep.startswith('calendar.list_integrations') or ep.startswith('calendar.connect') or ep.startswith('calendar.disconnect') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_calendar %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('calendar.view_calendar') }}">
|
|
<i class="fas fa-calendar w-4 mr-2"></i>{{ _('Calendar View') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_calendar_integrations %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('calendar.list_integrations') }}">
|
|
<i class="fas fa-plug w-4 mr-2"></i>{{ _('Integrations') }}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
<li class="mt-3 pt-3 border-t border-border-light dark:border-border-dark">
|
|
<button data-dropdown="workDropdown" class="sidebar-nav-item w-full flex items-center p-2 rounded-lg border-l-4 {% if work_open %}border-primary bg-primary/5 dark:bg-primary/10 text-primary font-semibold{% else %}border-transparent text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" title="{{ _('Time Tracking') }}">
|
|
<i class="fas fa-briefcase w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Time Tracking') }}</span>
|
|
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
|
|
</button>
|
|
<ul id="workDropdown" class="{% if not work_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% set nav_active_timer = ep.startswith('timer.manual') %}
|
|
{% set nav_active_time_entries = ep == 'timer.time_entries_overview' %}
|
|
{% set nav_active_time_approvals = ep.startswith('time_approvals.') %}
|
|
{% set nav_active_projects = ep.startswith('projects.') %}
|
|
{% set nav_active_clients = ep.startswith('clients.') %}
|
|
{% set nav_active_quotes = ep.startswith('quotes.') %}
|
|
{% set nav_active_tasks = ep.startswith('tasks.') %}
|
|
{% set nav_active_issues = ep.startswith('issues.') %}
|
|
{% set nav_active_kanban = ep.startswith('kanban.') %}
|
|
{% set nav_active_templates = ep.startswith('time_entry_templates.') %}
|
|
{% set nav_active_goals = ep.startswith('weekly_goals.') %}
|
|
{% set nav_active_project_templates = ep.startswith('project_templates.') %}
|
|
{% set nav_active_workforce = ep.startswith('workforce.') %}
|
|
{% if is_module_enabled('time_approvals') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_time_approvals %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('time_approvals.list_approvals') }}">
|
|
<i class="fas fa-check-double w-4 mr-2"></i>{{ _('Time Approvals') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_projects %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('projects.list_projects') }}">
|
|
<i class="fas fa-folder w-4 mr-2"></i>{{ _('Projects') }}
|
|
</a>
|
|
</li>
|
|
{% if is_module_enabled('project_templates') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_project_templates %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('project_templates.list_templates') }}">
|
|
<i class="fas fa-layer-group w-4 mr-2"></i>{{ _('Project Templates') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('gantt') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if ep.startswith('gantt.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('gantt.gantt_view') }}">
|
|
<i class="fas fa-project-diagram w-4 mr-2"></i>{{ _('Gantt Chart') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_tasks %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('tasks.list_tasks') }}">
|
|
<i class="fas fa-tasks w-4 mr-2"></i>{{ _('Tasks') }}
|
|
</a>
|
|
</li>
|
|
{% if is_module_enabled('issues') and (current_user.is_admin or has_permission('view_all_issues') or has_permission('create_issues')) %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_issues %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('issues.list_issues') }}">
|
|
<i class="fas fa-bug w-4 mr-2"></i>{{ _('Issues') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('kanban') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_kanban %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('kanban.board') }}">
|
|
<i class="fas fa-columns w-4 mr-2"></i>{{ _('Kanban Board') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('weekly_goals') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_goals %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('weekly_goals.index') }}">
|
|
<i class="fas fa-bullseye w-4 mr-2"></i>{{ _('Weekly Goals') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_workforce %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('workforce.dashboard') }}">
|
|
<i class="fas fa-user-clock w-4 mr-2"></i>{{ _('Workforce') }}
|
|
</a>
|
|
</li>
|
|
{% if is_module_enabled('time_entry_templates') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_templates %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('time_entry_templates.list_templates') }}">
|
|
<i class="fas fa-clipboard-list w-4 mr-2"></i>{{ _('Time Entry Templates') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% if has_enabled_modules(ModuleCategory.CRM) %}
|
|
<li class="mt-3 pt-3 border-t border-border-light dark:border-border-dark">
|
|
<button data-dropdown="crmDropdown" class="sidebar-nav-item w-full flex items-center p-2 rounded-lg border-l-4 {% if crm_open %}border-primary bg-primary/5 dark:bg-primary/10 text-primary font-semibold{% else %}border-transparent text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" title="{{ _('CRM') }}">
|
|
<i class="fas fa-handshake w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('CRM') }}</span>
|
|
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
|
|
</button>
|
|
<ul id="crmDropdown" class="{% if not crm_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% set nav_active_clients = ep.startswith('clients.') %}
|
|
{% set nav_active_quotes = ep.startswith('quotes.') %}
|
|
{% set nav_active_contacts = ep.startswith('contacts.') %}
|
|
{% set nav_active_deals = ep.startswith('deals.') %}
|
|
{% set nav_active_leads = ep.startswith('leads.') %}
|
|
{% if is_module_enabled('clients') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_clients %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('clients.list_clients') }}">
|
|
<i class="fas fa-users w-4 mr-2"></i>{{ _('Clients') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('contacts') %}
|
|
<li>
|
|
<span class="block px-2 py-1 text-text-muted-light dark:text-text-muted-dark text-sm">
|
|
<i class="fas fa-address-book w-4 mr-2"></i>{{ _('Contacts') }}
|
|
<span class="text-xs ml-2">({{ _('via Clients') }})</span>
|
|
</span>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('deals') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_deals %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('deals.list_deals') }}">
|
|
<i class="fas fa-handshake w-4 mr-2"></i>{{ _('Deals') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('leads') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_leads %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('leads.list_leads') }}">
|
|
<i class="fas fa-user-tag w-4 mr-2"></i>{{ _('Leads') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('quotes') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_quotes %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('quotes.list_quotes') }}">
|
|
<i class="fas fa-file-contract w-4 mr-2"></i>{{ _('Quotes') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
{% if has_enabled_modules(ModuleCategory.FINANCE) %}
|
|
<li class="mt-3 pt-3 border-t border-border-light dark:border-border-dark">
|
|
<button data-dropdown="financeDropdown" class="sidebar-nav-item w-full flex items-center p-2 rounded-lg {% if finance_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-dollar-sign w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Finance & Expenses') }}</span>
|
|
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
|
|
</button>
|
|
<ul id="financeDropdown" class="{% if not finance_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% set nav_active_invoices = ep.startswith('invoices.') %}
|
|
{% set nav_active_invoice_approvals = ep.startswith('invoice_approvals.') %}
|
|
{% set nav_active_payment_gateways = ep.startswith('payment_gateways.') %}
|
|
{% set nav_active_recurring_invoices = ep.startswith('recurring_invoices.') %}
|
|
{% set nav_active_payments = ep.startswith('payments.') %}
|
|
{% set nav_active_expenses = ep.startswith('expenses.') %}
|
|
{% set nav_active_mileage = ep.startswith('mileage.') %}
|
|
{% set nav_active_perdiem = ep.startswith('per_diem.') and not ep.startswith('per_diem.list_rates') %}
|
|
{% set nav_active_budget = ep.startswith('budget_alerts.') %}
|
|
{% set reports_open = ep.startswith('reports.') or ep.startswith('scheduled_reports.') or ep.startswith('custom_reports.') %}
|
|
{% if is_module_enabled('reports') %}
|
|
<li>
|
|
<button data-dropdown="reportsDropdown" class="w-full flex items-center px-2 py-1 rounded {% if reports_open %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-chart-bar w-4 mr-2"></i>
|
|
<span class="flex-1 text-left">{{ _('Reports') }}</span>
|
|
<i class="fas fa-chevron-down text-xs"></i>
|
|
</button>
|
|
<ul id="reportsDropdown" class="{% if not reports_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% set nav_active_reports = ep.startswith('reports.') and not ep.startswith('scheduled_reports.') and not ep.startswith('custom_reports.') %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('reports.reports') }}">
|
|
<i class="fas fa-chart-line w-4 mr-2"></i>{{ _('All Reports') }}
|
|
</a>
|
|
</li>
|
|
{% if is_module_enabled('custom_reports') %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'custom_reports.report_builder' or ep == 'custom_reports.view_custom_report' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('custom_reports.report_builder') }}">
|
|
<i class="fas fa-magic w-4 mr-2"></i>{{ _('Report Builder') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'custom_reports.list_saved_views' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('custom_reports.list_saved_views') }}">
|
|
<i class="fas fa-save w-4 mr-2"></i>{{ _('Saved Views') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('scheduled_reports') %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep.startswith('scheduled_reports.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('scheduled_reports.list_scheduled') }}">
|
|
<i class="fas fa-clock w-4 mr-2"></i>{{ _('Scheduled Reports') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('invoices') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('invoices.list_invoices') }}">
|
|
<i class="fas fa-file-invoice w-4 mr-2"></i>{{ _('Invoices') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('invoice_approvals') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_invoice_approvals %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('invoice_approvals.list_approvals') }}">
|
|
<i class="fas fa-check-circle w-4 mr-2"></i>{{ _('Invoice Approvals') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('payment_gateways') and has_endpoint('payment_gateways.list_gateways') and (current_user.is_admin or has_permission('manage_payment_gateways')) %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_payment_gateways %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('payment_gateways.list_gateways') }}">
|
|
<i class="fas fa-credit-card w-4 mr-2"></i>{{ _('Payment Gateways') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('recurring_invoices') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_recurring_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('recurring_invoices.list_recurring_invoices') }}">
|
|
<i class="fas fa-sync-alt w-4 mr-2"></i>{{ _('Recurring Invoices') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('payments') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_payments %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('payments.list_payments') }}">
|
|
<i class="fas fa-credit-card w-4 mr-2"></i>{{ _('Payments') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('expenses') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_expenses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expenses.list_expenses') }}">
|
|
<i class="fas fa-receipt w-4 mr-2"></i>{{ _('Expenses') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('mileage') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_mileage %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('mileage.list_mileage') }}">
|
|
<i class="fas fa-car w-4 mr-2"></i>{{ _('Mileage') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('per_diem') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_perdiem %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('per_diem.list_per_diem') }}">
|
|
<i class="fas fa-utensils w-4 mr-2"></i>{{ _('Per Diem') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('budget_alerts') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_budget %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('budget_alerts.budget_dashboard') }}">
|
|
<i class="fas fa-exclamation-triangle w-4 mr-2"></i>{{ _('Budget Alerts') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('inventory') and (current_user.is_admin or has_permission('view_inventory')) %}
|
|
<li class="mt-3 pt-3 border-t border-border-light dark:border-border-dark">
|
|
<button data-dropdown="inventoryDropdown" class="sidebar-nav-item w-full flex items-center p-2 rounded-lg {% if inventory_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-boxes w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Inventory') }}</span>
|
|
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
|
|
</button>
|
|
<ul id="inventoryDropdown" class="{% if not inventory_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% set nav_active_stock_items = ep.startswith('inventory.list_stock_items') or ep.startswith('inventory.view_stock_item') or ep.startswith('inventory.new_stock_item') or ep.startswith('inventory.edit_stock_item') %}
|
|
{% set nav_active_warehouses = ep.startswith('inventory.list_warehouses') or ep.startswith('inventory.view_warehouse') or ep.startswith('inventory.new_warehouse') or ep.startswith('inventory.edit_warehouse') %}
|
|
{% set nav_active_suppliers = ep.startswith('inventory.list_suppliers') or ep.startswith('inventory.view_supplier') or ep.startswith('inventory.new_supplier') or ep.startswith('inventory.edit_supplier') %}
|
|
{% set nav_active_stock_levels = ep.startswith('inventory.stock_levels') %}
|
|
{% set nav_active_movements = ep.startswith('inventory.list_movements') or ep.startswith('inventory.new_movement') %}
|
|
{% set nav_active_transfers = ep.startswith('inventory.list_transfers') or ep.startswith('inventory.new_transfer') %}
|
|
{% set nav_active_adjustments = ep.startswith('inventory.list_adjustments') or ep.startswith('inventory.new_adjustment') %}
|
|
{% set nav_active_reservations = ep.startswith('inventory.list_reservations') %}
|
|
{% set nav_active_low_stock = ep.startswith('inventory.low_stock_alerts') %}
|
|
{% set nav_active_reports = ep.startswith('inventory.reports') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_stock_items %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_stock_items') }}">
|
|
<i class="fas fa-cubes w-4 mr-2"></i>{{ _('Stock Items') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_warehouses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_warehouses') }}">
|
|
<i class="fas fa-warehouse w-4 mr-2"></i>{{ _('Warehouses') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_suppliers %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_suppliers') }}">
|
|
<i class="fas fa-truck w-4 mr-2"></i>{{ _('Suppliers') }}
|
|
</a>
|
|
</li>
|
|
{% set nav_active_purchase_orders = ep.startswith('inventory.list_purchase_orders') or ep.startswith('inventory.view_purchase_order') or ep.startswith('inventory.new_purchase_order') or ep.startswith('inventory.receive_purchase_order') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_purchase_orders %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_purchase_orders') }}">
|
|
<i class="fas fa-shopping-cart w-4 mr-2"></i>{{ _('Purchase Orders') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_stock_levels %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.stock_levels') }}">
|
|
<i class="fas fa-list-ul w-4 mr-2"></i>{{ _('Stock Levels') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_movements %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_movements') }}">
|
|
<i class="fas fa-exchange-alt w-4 mr-2"></i>{{ _('Stock Movements') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_transfers %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_transfers') }}">
|
|
<i class="fas fa-truck w-4 mr-2"></i>{{ _('Transfers') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_adjustments %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_adjustments') }}">
|
|
<i class="fas fa-edit w-4 mr-2"></i>{{ _('Adjustments') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_reservations %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.list_reservations') }}">
|
|
<i class="fas fa-bookmark w-4 mr-2"></i>{{ _('Reservations') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_low_stock %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.low_stock_alerts') }}">
|
|
<i class="fas fa-exclamation-triangle w-4 mr-2"></i>{{ _('Low Stock Alerts') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('inventory.reports_dashboard') }}">
|
|
<i class="fas fa-chart-pie w-4 mr-2"></i>{{ _('Reports') }}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('analytics') %}
|
|
<li class="mt-3 pt-3 border-t border-border-light dark:border-border-dark">
|
|
<a href="{{ url_for('analytics.analytics_dashboard') }}" class="sidebar-nav-item flex items-center p-2 rounded-lg {% if analytics_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-chart-line w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Analytics') }}</span>
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('integrations') or is_module_enabled('import_export') or is_module_enabled('saved_filters') %}
|
|
<li class="mt-3 pt-3 border-t border-border-light dark:border-border-dark">
|
|
<button data-dropdown="toolsDropdown" class="sidebar-nav-item w-full flex items-center p-2 rounded-lg {% if tools_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-tools w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Tools & Data') }}</span>
|
|
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
|
|
</button>
|
|
<ul id="toolsDropdown" class="{% if not tools_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% set nav_active_import_export = ep.startswith('import_export.') %}
|
|
{% set nav_active_filters = ep.startswith('saved_filters.') %}
|
|
{% set nav_active_integrations = ep.startswith('integrations.') or ep == 'integrations.list_integrations' or ep == 'integrations.manage_integration' or ep == 'integrations.view_integration' or ep == 'integrations.connect_integration' or ep == 'integrations.caldav_setup' %}
|
|
{% if is_module_enabled('integrations') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_integrations %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('integrations.list_integrations') }}">
|
|
<i class="fas fa-plug w-4 mr-2"></i>{{ _('Integrations') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('import_export') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_import_export %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('import_export.import_export_page') }}">
|
|
<i class="fas fa-exchange-alt w-4 mr-2"></i>{{ _('Import / Export') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if is_module_enabled('saved_filters') %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if nav_active_filters %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('saved_filters.list_filters') }}">
|
|
<i class="fas fa-filter w-4 mr-2"></i>{{ _('Saved Filters') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
{% if current_user.is_admin or has_any_permission('view_users', 'manage_settings', 'view_system_info', 'manage_backups') %}
|
|
<li class="mt-3 pt-3 border-t border-border-light dark:border-border-dark">
|
|
<button data-dropdown="adminDropdown" class="sidebar-nav-item w-full flex items-center p-2 rounded-lg {% if admin_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-cog w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Admin') }}</span>
|
|
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
|
|
</button>
|
|
<ul id="adminDropdown" class="{% if not admin_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
<!-- Dashboard -->
|
|
{% if current_user.is_admin %}
|
|
<li>
|
|
<a class="block px-2 py-1 rounded {% if ep == 'admin.admin_dashboard' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.admin_dashboard') }}">
|
|
<i class="fas fa-tachometer-alt w-4 mr-2"></i>{{ _('Admin Dashboard') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
|
|
<!-- User Management Submenu -->
|
|
{% if current_user.is_admin or has_permission('view_users') %}
|
|
<li>
|
|
<button data-dropdown="adminUserMgmtDropdown" class="w-full flex items-center px-2 py-1 rounded {% if admin_user_mgmt_open %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-users w-4 mr-2"></i>
|
|
<span class="flex-1 text-left">{{ _('User Management') }}</span>
|
|
<i class="fas fa-chevron-down text-xs"></i>
|
|
</button>
|
|
<ul id="adminUserMgmtDropdown" class="{% if admin_user_mgmt_open %}{% else %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% if current_user.is_admin or has_permission('view_users') %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.list_users' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.list_users') }}">
|
|
<i class="fas fa-users-cog w-4 mr-2"></i>{{ _('Manage Users') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if current_user.is_admin %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep.startswith('permissions.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('permissions.list_roles') }}">
|
|
<i class="fas fa-shield-alt w-4 mr-2"></i>{{ _('Roles & Permissions') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
|
|
<!-- PDF Templates Submenu (top-level under Admin so it opens without opening System Settings first) -->
|
|
{% if current_user.is_admin or has_permission('manage_settings') %}
|
|
<li>
|
|
<button data-dropdown="pdfDropdown" class="w-full flex items-center px-2 py-1 rounded {% if pdf_open %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-file-pdf w-4 mr-2"></i>
|
|
<span class="flex-1 text-left">{{ _('PDF Templates') }}</span>
|
|
<i class="fas fa-chevron-down text-xs"></i>
|
|
</button>
|
|
<ul id="pdfDropdown" class="{% if pdf_open %}{% else %}hidden {% endif %}mt-2 space-y-2 ml-6" data-no-propagation="true">
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.pdf_layout' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.pdf_layout') }}">
|
|
<i class="fas fa-file-invoice w-4 mr-2"></i>{{ _('Invoice PDF') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.quote_pdf_layout' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.quote_pdf_layout') }}">
|
|
<i class="fas fa-file-contract w-4 mr-2"></i>{{ _('Quote PDF') }}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
|
|
<!-- System Settings Submenu -->
|
|
{% if current_user.is_admin or has_permission('manage_settings') or has_permission('manage_oidc') %}
|
|
<li>
|
|
<button data-dropdown="adminSettingsDropdown" class="w-full flex items-center px-2 py-1 rounded {% if admin_settings_open %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-cog w-4 mr-2"></i>
|
|
<span class="flex-1 text-left">{{ _('System Settings') }}</span>
|
|
<i class="fas fa-chevron-down text-xs"></i>
|
|
</button>
|
|
<ul id="adminSettingsDropdown" class="{% if admin_settings_open %}{% else %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% if current_user.is_admin or has_permission('manage_settings') %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.settings' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.settings') }}">
|
|
<i class="fas fa-sliders-h w-4 mr-2"></i>{{ _('Settings') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.manage_modules' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.manage_modules') }}">
|
|
<i class="fas fa-puzzle-piece w-4 mr-2"></i>{{ _('Module Management') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.email_support' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.email_support') }}">
|
|
<i class="fas fa-envelope w-4 mr-2"></i>{{ _('Email Configuration') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep.startswith('admin.') and ('email_template' in ep or 'email-templates' in request.path) %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.list_email_templates') }}">
|
|
<i class="fas fa-envelope-open-text w-4 mr-2"></i>{{ _('Email Templates') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if current_user.is_admin or has_permission('manage_oidc') %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.oidc_debug' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.oidc_debug') }}">
|
|
<i class="fas fa-lock w-4 mr-2"></i>{{ _('OIDC Settings') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
|
|
<!-- Security & Access Submenu -->
|
|
{% if current_user.is_admin or has_permission('view_audit_logs') %}
|
|
<li>
|
|
<button data-dropdown="adminSecurityDropdown" class="w-full flex items-center px-2 py-1 rounded {% if admin_security_open %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-shield-alt w-4 mr-2"></i>
|
|
<span class="flex-1 text-left">{{ _('Security & Access') }}</span>
|
|
<i class="fas fa-chevron-down text-xs"></i>
|
|
</button>
|
|
<ul id="adminSecurityDropdown" class="{% if admin_security_open %}{% else %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% if current_user.is_admin %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.api_tokens' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.api_tokens') }}">
|
|
<i class="fas fa-key w-4 mr-2"></i>{{ _('API Tokens') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if current_user.is_admin %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep.startswith('webhooks.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('webhooks.list_webhooks') }}">
|
|
<i class="fas fa-plug w-4 mr-2"></i>{{ _('Webhooks') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if current_user.is_admin or has_permission('view_audit_logs') %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep.startswith('audit_logs.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('audit_logs.list_audit_logs') }}">
|
|
<i class="fas fa-history w-4 mr-2"></i>{{ _('Audit Logs') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
<!-- Data Management Submenu -->
|
|
{% if current_user.is_admin or has_permission('manage_settings') %}
|
|
<li>
|
|
<button data-dropdown="adminDataDropdown" class="w-full flex items-center px-2 py-1 rounded {% if admin_data_open %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-database w-4 mr-2"></i>
|
|
<span class="flex-1 text-left">{{ _('Data Management') }}</span>
|
|
<i class="fas fa-chevron-down text-xs"></i>
|
|
</button>
|
|
<ul id="adminDataDropdown" class="{% if admin_data_open %}{% else %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% if current_user.is_admin %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'expense_categories.list_categories' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expense_categories.list_categories') }}">
|
|
<i class="fas fa-tags w-4 mr-2"></i>{{ _('Expense Categories') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'per_diem.list_rates' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('per_diem.list_rates') }}">
|
|
<i class="fas fa-list-ul w-4 mr-2"></i>{{ _('Per Diem Rates') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if current_user.is_admin or has_permission('manage_settings') %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if nav_active_templates %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('time_entry_templates.list_templates') }}">
|
|
<i class="fas fa-file-lines w-4 mr-2"></i>{{ _('Time Entry Templates') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep.startswith('custom_field_definitions.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('custom_field_definitions.list_custom_field_definitions') }}">
|
|
<i class="fas fa-tags w-4 mr-2"></i>{{ _('Custom Fields') }}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep.startswith('link_templates.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('link_templates.list_link_templates') }}">
|
|
<i class="fas fa-link w-4 mr-2"></i>{{ _('Link Templates') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
|
|
<!-- System Maintenance Submenu -->
|
|
{% if current_user.is_admin or has_permission('view_system_info') or has_permission('manage_backups') %}
|
|
<li>
|
|
<button data-dropdown="adminMaintenanceDropdown" class="w-full flex items-center px-2 py-1 rounded {% if admin_maintenance_open %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
|
<i class="fas fa-tools w-4 mr-2"></i>
|
|
<span class="flex-1 text-left">{{ _('System Maintenance') }}</span>
|
|
<i class="fas fa-chevron-down text-xs"></i>
|
|
</button>
|
|
<ul id="adminMaintenanceDropdown" class="{% if admin_maintenance_open %}{% else %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
|
{% if current_user.is_admin or has_permission('view_system_info') %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.system_info' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.system_info') }}">
|
|
<i class="fas fa-info-circle w-4 mr-2"></i>{{ _('System Info') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if current_user.is_admin or has_permission('manage_backups') %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.backups_management' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.backups_management') }}">
|
|
<i class="fas fa-database w-4 mr-2"></i>{{ _('Backups') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
{% if current_user.is_admin %}
|
|
<li>
|
|
<a data-no-propagation="true" class="block px-2 py-1 rounded {% if ep == 'admin.telemetry_dashboard' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.telemetry_dashboard') }}">
|
|
<i class="fas fa-chart-line w-4 mr-2"></i>{{ _('Telemetry') }}
|
|
</a>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</nav>
|
|
<div class="mt-auto pt-4 border-t border-border-light dark:border-border-dark">
|
|
<ul>
|
|
<li>
|
|
<a href="{{ url_for('main.about') }}" class="sidebar-nav-item flex items-center p-2 rounded-lg text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
|
|
<i class="fas fa-circle-info w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('About') }}</span>
|
|
</a>
|
|
</li>
|
|
<li class="mt-2">
|
|
<a href="{{ url_for('main.help') }}" class="sidebar-nav-item flex items-center p-2 rounded-lg text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
|
|
<i class="fas fa-life-ring w-6 text-center"></i>
|
|
<span class="ml-3 sidebar-label">{{ _('Help') }}</span>
|
|
</a>
|
|
</li>
|
|
{% if current_user.is_authenticated and current_user.ui_show_donate and not is_license_activated %}
|
|
<li class="mt-2">
|
|
<button type="button" class="sidebar-nav-item w-full text-left flex items-center p-2 rounded-lg bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 hover:from-amber-500/20 hover:to-orange-500/20 hover:border-amber-500/30 transition-all duration-200 group js-open-support-modal" title="{{ _('Open support options') }}">
|
|
<i class="fas fa-heart w-6 text-center group-hover:scale-110 transition-transform" aria-hidden="true"></i>
|
|
<span class="ml-3 sidebar-label font-medium">{{ _('Support TimeTracker') }}</span>
|
|
</button>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
<!-- App Version -->
|
|
<div class="mt-4 pt-3 border-t border-border-light dark:border-border-dark">
|
|
<div class="sidebar-version-row flex items-center justify-center px-2 py-1 text-xs text-text-muted-light dark:text-text-muted-dark" title="v{{ app_version }}">
|
|
<i class="fas fa-code-branch w-4 text-center shrink-0" aria-hidden="true"></i>
|
|
<span class="ml-2 sidebar-version-text truncate">v{{ app_version }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main content: pb for mobile bottom nav + safe-area -->
|
|
<div id="mainContent" class="flex-1 flex flex-col min-h-screen min-w-0 transition-all duration-200 ease-in-out md:ml-64 md:pb-0 pb-16">
|
|
<!-- Header -->
|
|
<header class="bg-card-light dark:bg-card-dark p-3 sm:p-4 border-b border-border-light dark:border-border-dark flex items-center gap-2 min-w-0 shadow-sm">
|
|
<!-- Mobile menu button -->
|
|
<button id="mobileSidebarBtn" class="md:hidden shrink-0 flex items-center justify-center w-10 h-10 rounded-lg text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark" aria-label="{{ _('Open sidebar') }}" type="button">
|
|
<i class="fas fa-bars text-lg"></i>
|
|
</button>
|
|
|
|
<!-- Search (Ctrl+K opens command palette) -->
|
|
<div class="hidden sm:flex flex-1 min-w-0 justify-center px-2">
|
|
<div class="w-full max-w-md relative">
|
|
<form class="navbar-search" role="search" action="{{ url_for('main.search') }}" method="get" autocomplete="off">
|
|
<label for="header-search" class="sr-only">{{ _('Search') }}</label>
|
|
<input id="header-search" name="q" type="search" placeholder="{{ _('Search') }}" aria-label="{{ _('Search') }}" data-enhanced-search='{"endpoint": "/api/search", "minChars": 2, "maxResults": 10}' class="w-full bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 pl-10 pr-20 text-text-light dark:text-text-dark placeholder-text-muted-light dark:placeholder-text-muted-dark focus:outline-none focus:ring-2 focus:ring-primary" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
|
</form>
|
|
<kbd class="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-600 text-text-muted-light dark:text-text-muted-dark border border-gray-200 dark:border-gray-500" title="{{ _('Open command palette') }}">Ctrl+K</kbd>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right side controls -->
|
|
<div class="flex items-center gap-2">
|
|
<div class="relative z-50" id="theme-dropdown-container">
|
|
<button id="theme-toggle" type="button" class="flex items-center justify-center w-9 h-9 rounded-lg text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/50" aria-label="{{ _('Theme') }}" aria-haspopup="true" aria-expanded="false" aria-controls="themeDropdown">
|
|
<i id="theme-toggle-dark-icon" class="hidden fa-solid fa-moon w-5 h-5"></i>
|
|
<i id="theme-toggle-light-icon" class="hidden fa-solid fa-sun w-5 h-5"></i>
|
|
<i id="theme-toggle-system-icon" class="hidden fa-solid fa-desktop w-5 h-5"></i>
|
|
</button>
|
|
<ul id="themeDropdown" class="hidden absolute right-0 mt-2 w-40 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg py-1" role="menu" aria-label="{{ _('Theme options') }}">
|
|
<li role="none"><button type="button" class="theme-option w-full text-left px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2" data-theme="light" role="menuitem"><i class="fas fa-sun w-4" aria-hidden="true"></i>{{ _('Light') }}</button></li>
|
|
<li role="none"><button type="button" class="theme-option w-full text-left px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2" data-theme="dark" role="menuitem"><i class="fas fa-moon w-4" aria-hidden="true"></i>{{ _('Dark') }}</button></li>
|
|
<li role="none"><button type="button" class="theme-option w-full text-left px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2" data-theme="system" role="menuitem"><i class="fas fa-desktop w-4" aria-hidden="true"></i>{{ _('System') }}</button></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Chat, Timer, Help - icon buttons -->
|
|
<div class="flex items-center gap-1">
|
|
{% if is_module_enabled('team_chat') %}
|
|
<button onclick="if(typeof toggleChatWidget === 'function') { toggleChatWidget(); } else { openChatUserSelector(); }" class="flex items-center justify-center w-9 h-9 rounded-lg text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/50" aria-label="{{ _('Open chat') }}" title="{{ _('Open chat') }}">
|
|
<i class="fas fa-comments"></i>
|
|
</button>
|
|
{% endif %}
|
|
{% if current_user.is_authenticated %}
|
|
<div id="floatingTimerBar" class="flex shrink-0 items-center justify-center w-9 h-9"
|
|
data-start-label="{{ _('Start Timer') }}"
|
|
data-stop-label="{{ _('Stop') }}"
|
|
data-manual-url="{{ url_for('timer.manual_entry') }}"
|
|
aria-label="{{ _('Timer') }}"></div>
|
|
{% endif %}
|
|
<a href="{{ url_for('main.help') }}" class="flex items-center justify-center w-9 h-9 rounded-lg text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/50" aria-label="{{ _('Help') }}" title="{{ _('Help') }}">
|
|
<i class="fas fa-life-ring"></i>
|
|
</a>
|
|
{% if current_user.is_authenticated %}
|
|
<button type="button" data-ai-helper-open class="flex items-center justify-center w-9 h-9 rounded-lg text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/50" aria-label="{{ _('AI Helper') }}" title="{{ _('AI Helper') }}">
|
|
<i class="fas fa-wand-magic-sparkles"></i>
|
|
</button>
|
|
{% endif %}
|
|
{% if current_user.is_authenticated and not is_license_activated %}
|
|
<button type="button" id="headerSupportBtn" class="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm font-medium text-amber-800 dark:text-amber-200 bg-amber-500/15 hover:bg-amber-500/25 border border-amber-500/30 dark:border-amber-500/40 focus:outline-none focus:ring-2 focus:ring-amber-500/40" title="{{ _('Support TimeTracker') }}">
|
|
<i class="fas fa-heart text-amber-600 dark:text-amber-300" aria-hidden="true"></i>
|
|
<span class="max-w-[10rem] truncate">{{ _('Support TimeTracker') }}</span>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Language Switcher -->
|
|
<div class="relative z-50">
|
|
<button data-dropdown="langDropdown" class="flex items-center justify-center w-9 h-9 rounded-lg text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/50 lg:w-auto lg:px-2.5" aria-label="{{ _('Change language') }}" aria-haspopup="true" aria-expanded="false" aria-controls="langDropdown" title="{{ _('Change language') }}">
|
|
<i class="fas fa-globe"></i>
|
|
<span class="ml-2 hidden lg:inline">{{ current_language_label }}</span>
|
|
</button>
|
|
<ul id="langDropdown" class="hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg max-h-96 overflow-y-auto">
|
|
<li class="px-4 py-2 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider border-b border-border-light dark:border-border-dark">{{ _('Language') }}</li>
|
|
{% for code, label in available_languages.items() %}
|
|
<li>
|
|
<a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-between" href="{{ url_for('user.set_language_direct', language=code) }}">
|
|
<span>{{ label }}</span>
|
|
{% if code == current_language_code %}
|
|
<i class="fas fa-check text-primary"></i>
|
|
{% endif %}
|
|
</a>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- User Profile -->
|
|
<div class="relative z-50">
|
|
<button data-dropdown="userDropdown" class="flex items-center space-x-2" aria-label="{{ _('User menu') }}" aria-haspopup="true" aria-expanded="false" aria-controls="userDropdown">
|
|
{% if current_user.is_authenticated %}
|
|
{% if current_user.get_avatar_url() %}
|
|
<img id="userAvatar" src="{{ current_user.get_avatar_url() }}" alt="{{ current_user.display_name }}" class="w-8 h-8 rounded-full object-cover border-2 border-primary" onerror="this.onerror=null; this.style.display='none'; document.getElementById('userAvatarFallback').style.display='flex';">
|
|
<div id="userAvatarFallback" class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center font-semibold text-sm" style="display: none;">
|
|
{{ current_user.display_name[0:1].upper() }}
|
|
</div>
|
|
{% else %}
|
|
<div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center font-semibold text-sm border-2 border-primary">
|
|
{{ current_user.display_name[0:1].upper() }}
|
|
</div>
|
|
{% endif %}
|
|
<span class="hidden md:inline text-text-light dark:text-text-dark font-medium">{{ current_user.display_name }}</span>
|
|
{% if is_license_activated %}
|
|
<span class="hidden md:inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200 border border-emerald-200/80 dark:border-emerald-700/60" title="{{ _('Supporter') }}">{{ _('Supporter') }}</span>
|
|
{% endif %}
|
|
{% else %}
|
|
<div class="w-8 h-8 rounded-full bg-gray-400 flex items-center justify-center">
|
|
<i class="fas fa-user text-white"></i>
|
|
</div>
|
|
<span class="hidden md:inline text-text-light dark:text-text-dark">{{ _('Guest') }}</span>
|
|
{% endif %}
|
|
<i class="fas fa-chevron-down text-text-muted-light dark:text-text-muted-dark hidden md:inline" aria-hidden="true"></i>
|
|
</button>
|
|
<ul id="userDropdown" class="hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
|
<li class="px-4 py-3 border-b border-border-light dark:border-border-dark">
|
|
<div class="text-sm font-medium text-text-light dark:text-text-dark">{{ current_user.display_name if current_user.is_authenticated else _('Guest') }}</div>
|
|
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ current_user.email if current_user.is_authenticated else '' }}</div>
|
|
</li>
|
|
<li><a href="{{ url_for('auth.profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-user w-4"></i> {{ _('My Profile') }}</a></li>
|
|
<li><a href="{{ url_for('user.settings') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('My Settings') }}</a></li>
|
|
{% if current_user.is_authenticated %}
|
|
<li><a href="{{ url_for('user.license') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-key w-4"></i> {{ _('License') }}</a></li>
|
|
{% endif %}
|
|
{% if current_user.is_authenticated and not is_license_activated %}
|
|
<li><button type="button" class="js-open-support-modal w-full text-left flex flex-col gap-0.5 px-4 py-2 text-sm text-amber-600 dark:text-amber-400 hover:bg-gray-100 dark:hover:bg-gray-700"><span class="font-medium"><i class="fas fa-heart w-4" aria-hidden="true"></i> {{ _('Support TimeTracker') }}</span><span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Donate or get a supporter license') }}</span></button></li>
|
|
{% endif %}
|
|
<li class="border-t border-border-light dark:border-border-dark"><a href="{{ url_for('auth.logout') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-rose-600 dark:text-rose-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-sign-out-alt w-4"></i> {{ _('Logout') }}</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Offline Indicator -->
|
|
{% include 'components/offline_indicator.html' %}
|
|
|
|
<!-- Chat User Selector (widget shell lives in #fabDock below) -->
|
|
{% if is_module_enabled('team_chat') %}
|
|
{% include 'components/chat_user_selector.html' %}
|
|
{% endif %}
|
|
|
|
<!-- Flash Messages (hidden, converted to toasts by toast-notifications.js) -->
|
|
<div id="flash-messages-container" class="hidden">
|
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
{% if messages %}
|
|
{% for category, message in messages %}
|
|
<div class="alert {% if category == 'success' %}alert-success{% elif category == 'error' %}alert-danger{% elif category == 'warning' %}alert-warning{% else %}alert-info{% endif %}" data-toast-message="{{ message }}" data-toast-type="{{ category }}"></div>
|
|
{% endfor %}
|
|
{% endif %}
|
|
{% endwith %}
|
|
</div>
|
|
|
|
{% if current_user.is_authenticated and current_user.ui_show_donate and not is_license_activated %}
|
|
<!-- Dismissible Support Banner -->
|
|
<div id="supportBanner" class="bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-3 opacity-0 invisible max-h-0 overflow-hidden transition-all duration-300 ease-in-out">
|
|
<div class="max-w-7xl mx-auto flex items-center justify-between gap-4">
|
|
<div class="flex items-center gap-3 flex-1">
|
|
<i class="fas fa-mug-saucer text-amber-600 dark:text-amber-400 text-lg"></i>
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium text-amber-900 dark:text-amber-100" id="bannerTitle">
|
|
{{ _('Enjoying TimeTracker?') }}
|
|
</p>
|
|
<p class="text-xs text-amber-700 dark:text-amber-300" id="bannerMessage">
|
|
{{ _('Support independent development — licenses are supporter badges, not paywalls.') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<a href="{{ url_for('main.donate') }}"
|
|
class="px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium rounded-lg transition-colors">
|
|
{{ _('Support / Get key') }}
|
|
</a>
|
|
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=banner&utm_campaign=support"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onclick="trackDonationClick('banner_bmc')"
|
|
class="px-3 py-1.5 bg-white hover:bg-amber-50 text-amber-600 text-sm font-medium rounded-lg transition-colors border border-amber-600">
|
|
<i class="fas fa-mug-saucer mr-1"></i>{{ _('Buy Me a Coffee') }}
|
|
</a>
|
|
<a href="https://www.paypal.com/donate/?hosted_button_id=KZB27X5LNGU3J"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onclick="trackDonationClick('banner_paypal')"
|
|
class="px-3 py-1.5 text-white text-sm font-medium rounded-lg transition-colors border border-blue-600 hover:opacity-90" style="background: linear-gradient(to right, #0070ba, #003087);">
|
|
<i class="fab fa-paypal mr-1"></i>{{ _('PayPal') }}
|
|
</a>
|
|
<button type="button" class="text-xs text-amber-700 dark:text-amber-300 hover:underline self-center js-open-support-modal">
|
|
{{ _('Support / License') }}
|
|
</button>
|
|
<button onclick="dismissSupportBanner()"
|
|
class="p-1.5 text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded transition-colors"
|
|
aria-label="{{ _('Dismiss') }}">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Main page content (max-width for readability on large screens) -->
|
|
<main id="mainContentAnchor" class="flex-1 min-w-0 p-4 sm:p-6 w-full max-w-7xl mx-auto overflow-x-hidden">
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
{% if current_user.is_authenticated %}
|
|
<p class="text-center text-xs text-text-muted-light dark:text-text-muted-dark px-4 pb-2 max-w-7xl mx-auto w-full">{{ _('Built by an independent developer') }}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if current_user.is_authenticated %}
|
|
<meta name="idle-timeout-minutes" content="{{ settings.idle_timeout_minutes if settings else 30 }}">
|
|
{% endif %}
|
|
|
|
{% if is_admin_user %}
|
|
<div id="adminVersionUpdateRoot" class="hidden fixed bottom-20 right-4 z-[60] max-w-sm w-[min(24rem,calc(100vw-2rem))] rounded-xl border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark shadow-xl" role="region" aria-label="{{ _('Software update') }}">
|
|
<div class="p-4 sm:p-5">
|
|
<div class="flex items-start gap-3">
|
|
<span class="text-2xl shrink-0" aria-hidden="true">🚀</span>
|
|
<div class="min-w-0 flex-1">
|
|
<h2 id="adminVersionUpdateTitle" class="text-sm font-semibold text-text-light dark:text-text-dark"></h2>
|
|
<p id="adminVersionUpdatePublished" class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark"></p>
|
|
<div id="adminVersionUpdateNotes" class="mt-2 text-sm text-text-light dark:text-text-dark whitespace-pre-wrap break-words max-h-40 overflow-y-auto"></div>
|
|
<button type="button" id="adminVersionUpdateReadMore" class="hidden mt-1 text-xs text-primary hover:underline">{{ _('Read more') }}</button>
|
|
</div>
|
|
<button type="button" id="adminVersionUpdateClose" class="shrink-0 p-1 rounded-md text-text-muted-light hover:bg-background-light dark:text-text-muted-dark dark:hover:bg-background-dark" aria-label="{{ _('Close') }}">×</button>
|
|
</div>
|
|
<div class="mt-4 flex flex-wrap gap-2">
|
|
<a id="adminVersionUpdateViewRelease" href="#" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center px-3 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-dark">{{ _('View Release') }}</a>
|
|
<button type="button" id="adminVersionUpdateDismiss" class="inline-flex items-center justify-center px-3 py-1.5 rounded-lg border border-border-light dark:border-border-dark text-sm">{{ _('Dismiss') }}</button>
|
|
<button type="button" id="adminVersionUpdateDismissVersion" class="inline-flex items-center justify-center px-3 py-1.5 rounded-lg border border-border-light dark:border-border-dark text-sm">{{ _("Don't show again for this version") }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if current_user.is_authenticated and support_ui_json %}
|
|
<script type="application/json" id="support-ui-bootstrap">{{ support_ui_json|safe }}</script>
|
|
{% include 'components/support_modal.html' %}
|
|
{% endif %}
|
|
|
|
{% if current_user.is_authenticated %}
|
|
{# Floating hub: Actions, Chat, AI. Admin: whole dock lifted to clear #adminVersionUpdateRoot. #}
|
|
<div id="fabDock"
|
|
class="{% if is_admin_user %}fab-dock--admin{% endif %}"
|
|
aria-label="{{ _('Quick actions dock') }}"
|
|
data-dashboard-url="{{ url_for('main.dashboard') }}"
|
|
data-manual-entry-url="{{ url_for('timer.manual_entry') }}"
|
|
data-new-task-url="{{ url_for('tasks.create_task') }}"
|
|
data-new-project-url="/projects/create"
|
|
data-new-client-url="/clients/create"
|
|
data-reports-url="/reports/">
|
|
<div id="unifiedActionsRoot" role="group" aria-label="{{ _('Actions') }}">
|
|
<div id="unifiedActionsMenu" class="hidden rounded-xl border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark shadow-2xl py-1 text-left" role="menu" aria-hidden="true">
|
|
<button type="button" class="unified-actions__item flex w-full items-center gap-3 px-3 py-2.5 text-sm text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark text-left" data-action="start" role="menuitem">
|
|
<span class="w-5 text-center text-primary" aria-hidden="true"><i class="fas fa-play text-xs"></i></span>
|
|
<span>{{ _('Start Timer') }}</span>
|
|
</button>
|
|
<button type="button" class="unified-actions__item flex w-full items-center gap-3 px-3 py-2.5 text-sm text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark text-left" data-action="log" role="menuitem">
|
|
<span class="w-5 text-center" aria-hidden="true"><i class="fas fa-clock text-xs"></i></span>
|
|
<span>{{ _('Log Time') }}</span>
|
|
</button>
|
|
<button type="button" class="unified-actions__item flex w-full items-center gap-3 px-3 py-2.5 text-sm text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark text-left" data-action="task" role="menuitem">
|
|
<span class="w-5 text-center" aria-hidden="true"><i class="fas fa-tasks text-xs"></i></span>
|
|
<span>{{ _('New Task') }}</span>
|
|
</button>
|
|
<div class="my-1 border-t border-border-light dark:border-border-dark"></div>
|
|
<button type="button" class="unified-actions__item flex w-full items-center gap-3 px-3 py-2.5 text-sm text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark text-left" data-action="project" role="menuitem">
|
|
<span class="w-5 text-center" aria-hidden="true"><i class="fas fa-folder-plus text-xs"></i></span>
|
|
<span>{{ _('New Project') }}</span>
|
|
</button>
|
|
<button type="button" class="unified-actions__item flex w-full items-center gap-3 px-3 py-2.5 text-sm text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark text-left" data-action="client" role="menuitem">
|
|
<span class="w-5 text-center" aria-hidden="true"><i class="fas fa-user-plus text-xs"></i></span>
|
|
<span>{{ _('New Client') }}</span>
|
|
</button>
|
|
<button type="button" class="unified-actions__item flex w-full items-center gap-3 px-3 py-2.5 text-sm text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark text-left" data-action="reports" role="menuitem">
|
|
<span class="w-5 text-center" aria-hidden="true"><i class="fas fa-chart-line text-xs"></i></span>
|
|
<span>{{ _('Reports') }}</span>
|
|
</button>
|
|
</div>
|
|
<button type="button" id="unifiedActionsFab" class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-primary text-white shadow-lg hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/50" aria-expanded="false" aria-haspopup="true" aria-controls="unifiedActionsMenu" title="{{ _('Actions') }}">
|
|
<i class="fas fa-bolt text-xl" aria-hidden="true"></i>
|
|
<span class="sr-only">{{ _('Actions') }}</span>
|
|
</button>
|
|
</div>
|
|
{% if is_module_enabled('team_chat') %}
|
|
{% include 'components/persistent_chat_widget.html' %}
|
|
{% endif %}
|
|
<div id="aiHelperRoot" class="shrink-0 relative z-[70]">
|
|
<button type="button" id="aiHelperFab" data-ai-helper-open class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-primary text-white shadow-lg hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary/50" aria-label="{{ _('Open AI Helper') }}" title="{{ _('AI Helper') }}">
|
|
<i class="fas fa-wand-magic-sparkles text-xl" aria-hidden="true"></i>
|
|
<span class="sr-only">{{ _('AI Helper') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="aiHelperBackdrop" class="hidden fixed inset-0 z-[80] bg-black/30" data-ai-helper-close></div>
|
|
<section id="aiHelperDrawer" class="hidden fixed inset-y-0 right-0 z-[90] w-full max-w-xl bg-card-light dark:bg-card-dark border-l border-border-light dark:border-border-dark shadow-2xl flex flex-col" aria-label="{{ _('AI Helper') }}" role="dialog" aria-modal="true">
|
|
<header class="p-4 border-b border-border-light dark:border-border-dark flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('AI Helper') }}</h2>
|
|
<p id="aiHelperProvider" class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Ask about your tracked work, summaries, gaps, and next actions.') }}</p>
|
|
</div>
|
|
<button type="button" data-ai-helper-close class="p-2 rounded-lg hover:bg-background-light dark:hover:bg-background-dark" aria-label="{{ _('Close') }}">×</button>
|
|
</header>
|
|
<div class="p-4 border-b border-border-light dark:border-border-dark">
|
|
<details class="text-sm">
|
|
<summary class="cursor-pointer font-medium text-text-light dark:text-text-dark">{{ _('Context included') }}</summary>
|
|
<pre id="aiHelperContext" class="mt-2 max-h-44 overflow-auto whitespace-pre-wrap rounded-lg bg-background-light dark:bg-background-dark p-3 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Loading context preview...') }}</pre>
|
|
</details>
|
|
</div>
|
|
<div id="aiHelperMessages" class="flex-1 overflow-y-auto p-4 space-y-3"></div>
|
|
<div id="aiHelperActions" class="hidden border-t border-border-light dark:border-border-dark p-4 space-y-2"></div>
|
|
<form id="aiHelperForm" class="border-t border-border-light dark:border-border-dark p-4 space-y-3">
|
|
<textarea id="aiHelperPrompt" rows="3" class="form-input w-full" placeholder="{{ _('Ask: What did I work on this week? Draft a time entry from these notes...') }}"></textarea>
|
|
<div class="flex items-center justify-between gap-3">
|
|
<p id="aiHelperStatus" class="text-xs text-text-muted-light dark:text-text-muted-dark"></p>
|
|
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark">{{ _('Send') }}</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
{% endif %}
|
|
|
|
</div>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
|
|
<script src="{{ url_for('static', filename='date-picker-init.js') }}"></script>
|
|
<!-- Enhanced UI scripts -->
|
|
<script src="{{ url_for('static', filename='enhanced-search.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='form-validation.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='toast-notifications.js') }}?v={{ app_version }}-toastfix1"></script>
|
|
{% if current_user.is_authenticated and support_ui_json %}
|
|
<script src="{{ url_for('static', filename='support-ui.js') }}?v={{ app_version }}-sup1"></script>
|
|
{% endif %}
|
|
<script src="{{ url_for('static', filename='enhanced-tables.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='interactions.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='offline-sync.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='mentions.js') }}"></script>
|
|
<!-- Floating timer bar -->
|
|
{% if current_user.is_authenticated %}
|
|
<script src="{{ url_for('static', filename='floating-actions.js') }}?v={{ app_version }}"></script>
|
|
<script src="{{ url_for('static', filename='floating-timer-bar.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='idle.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='ai-helper.js') }}?v={{ app_version }}-ai1"></script>
|
|
{% endif %}
|
|
{% if is_admin_user %}
|
|
<script src="{{ url_for('static', filename='admin-version-update.js') }}?v={{ app_version }}"></script>
|
|
{% endif %}
|
|
<!-- Global Command Palette -->
|
|
<script type="module" src="{{ url_for('static', filename='js/command-palette.js') }}?v={{ app_version }}"></script>
|
|
<script>window.__BASE_INIT__={timerStatus:"{{ url_for('timer.timer_status') }}",stopTimer:"{{ url_for('timer.stop_timer') }}",dashboard:"{{ url_for('main.dashboard') }}",manualEntry:"{{ url_for('timer.manual_entry') }}",newTask:"{{ url_for('tasks.create_task') }}"};</script>
|
|
<script src="{{ url_for('static', filename='base-init.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='mobile.js') }}"></script>
|
|
<script>
|
|
// Sidebar collapse logic with persisted state
|
|
(function(){
|
|
const appShell = document.getElementById('appShell');
|
|
const sidebar = document.getElementById('sidebar');
|
|
const main = document.getElementById('mainContent');
|
|
const collapseBtn = document.getElementById('sidebarCollapseBtn');
|
|
const mobileBtn = document.getElementById('mobileSidebarBtn');
|
|
let flyout;
|
|
|
|
function applyCollapsed(isCollapsed){
|
|
// Toggle a class on the shell and documentElement (early script uses documentElement) so both stay in sync
|
|
const icon = document.getElementById('sidebarCollapseIcon');
|
|
if (isCollapsed){
|
|
appShell.classList.add('sidebar-collapsed');
|
|
document.documentElement.classList.add('sidebar-collapsed');
|
|
sidebar.classList.add('w-16');
|
|
sidebar.classList.remove('w-64');
|
|
// Adjust main content margin for collapsed sidebar
|
|
main.classList.remove('md:ml-64');
|
|
main.classList.add('md:ml-16');
|
|
icon && icon.classList.remove('fa-angles-left');
|
|
icon && icon.classList.add('fa-angles-right');
|
|
} else {
|
|
appShell.classList.remove('sidebar-collapsed');
|
|
document.documentElement.classList.remove('sidebar-collapsed');
|
|
sidebar.classList.remove('w-16');
|
|
sidebar.classList.add('w-64');
|
|
// Adjust main content margin for expanded sidebar
|
|
main.classList.remove('md:ml-16');
|
|
main.classList.add('md:ml-64');
|
|
icon && icon.classList.remove('fa-angles-right');
|
|
icon && icon.classList.add('fa-angles-left');
|
|
}
|
|
}
|
|
|
|
// Initialize from storage
|
|
try {
|
|
const saved = localStorage.getItem('sidebar-collapsed');
|
|
applyCollapsed(saved === 'true');
|
|
} catch(_) {}
|
|
|
|
// Desktop toggle
|
|
collapseBtn && collapseBtn.addEventListener('click', function(){
|
|
const nowCollapsed = !appShell.classList.contains('sidebar-collapsed');
|
|
applyCollapsed(nowCollapsed);
|
|
try { localStorage.setItem('sidebar-collapsed', String(nowCollapsed)); } catch(_) {}
|
|
});
|
|
|
|
// Mobile toggle with backdrop and proper close handlers
|
|
let backdrop = null;
|
|
|
|
function createBackdrop() {
|
|
if (!backdrop) {
|
|
backdrop = document.createElement('div');
|
|
backdrop.id = 'sidebarBackdrop';
|
|
backdrop.className = 'fixed inset-0 bg-black/50 z-40 md:hidden';
|
|
backdrop.style.display = 'none';
|
|
document.body.appendChild(backdrop);
|
|
|
|
// Close sidebar when clicking backdrop
|
|
backdrop.addEventListener('click', closeMobileSidebar);
|
|
}
|
|
return backdrop;
|
|
}
|
|
|
|
function openMobileSidebar() {
|
|
const isSmallScreen = window.innerWidth < 768; // md breakpoint
|
|
if (!isSmallScreen) return;
|
|
|
|
sidebar.classList.remove('hidden');
|
|
sidebar.style.zIndex = '50';
|
|
|
|
const bd = createBackdrop();
|
|
bd.style.display = 'block';
|
|
}
|
|
|
|
function closeMobileSidebar() {
|
|
sidebar.classList.add('hidden');
|
|
sidebar.style.zIndex = '10';
|
|
|
|
if (backdrop) {
|
|
backdrop.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function isSmallScreen() {
|
|
return window.innerWidth < 768; // md breakpoint
|
|
}
|
|
|
|
// Mobile toggle button
|
|
mobileBtn && mobileBtn.addEventListener('click', function(e){
|
|
e.stopPropagation();
|
|
if (sidebar.classList.contains('hidden')) {
|
|
openMobileSidebar();
|
|
} else {
|
|
closeMobileSidebar();
|
|
}
|
|
});
|
|
|
|
// Close sidebar when clicking outside (on small screens)
|
|
document.addEventListener('click', function(e) {
|
|
if (!isSmallScreen()) return;
|
|
if (sidebar.classList.contains('hidden')) return;
|
|
|
|
// Don't close if clicking inside sidebar or on mobile button
|
|
const clickedInside = sidebar.contains(e.target);
|
|
const clickedMobileBtn = mobileBtn && mobileBtn.contains(e.target);
|
|
|
|
if (!clickedInside && !clickedMobileBtn) {
|
|
closeMobileSidebar();
|
|
}
|
|
});
|
|
|
|
// Close sidebar when clicking on navigation links only (on small screens).
|
|
// Do not close when clicking dropdown toggles (section headings) so the user can expand and then select a sub-item.
|
|
if (sidebar) {
|
|
const menuLinks = sidebar.querySelectorAll('a[href]');
|
|
menuLinks.forEach(link => {
|
|
link.addEventListener('click', function(e) {
|
|
if (!isSmallScreen()) return;
|
|
const href = (this.getAttribute('href') || '').trim();
|
|
if (!href || href === '#') return;
|
|
// Small delay to allow navigation to start
|
|
setTimeout(() => {
|
|
closeMobileSidebar();
|
|
}, 100);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Handle window resize - close sidebar if window becomes large
|
|
let resizeTimeout;
|
|
window.addEventListener('resize', function() {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(function() {
|
|
if (!isSmallScreen() && !sidebar.classList.contains('hidden')) {
|
|
// Window is now large, ensure sidebar is visible (not hidden)
|
|
sidebar.classList.remove('hidden');
|
|
if (backdrop) {
|
|
backdrop.style.display = 'none';
|
|
}
|
|
} else if (isSmallScreen() && !sidebar.classList.contains('hidden')) {
|
|
// Still small screen, ensure backdrop is shown if sidebar is open
|
|
const bd = createBackdrop();
|
|
if (!sidebar.classList.contains('hidden')) {
|
|
bd.style.display = 'block';
|
|
}
|
|
}
|
|
}, 150);
|
|
});
|
|
|
|
// Preserve sidebar scroll position across page navigations
|
|
(function() {
|
|
const SIDEBAR_SCROLL_KEY = 'sidebar-scroll-position';
|
|
const LAST_URL_KEY = 'last-navigation-url';
|
|
|
|
// Save sidebar scroll position before navigation
|
|
function saveSidebarScroll() {
|
|
if (sidebar && !isSmallScreen()) {
|
|
try {
|
|
const scrollTop = sidebar.scrollTop;
|
|
localStorage.setItem(SIDEBAR_SCROLL_KEY, String(scrollTop));
|
|
localStorage.setItem(LAST_URL_KEY, window.location.pathname);
|
|
} catch(e) {
|
|
// Ignore localStorage errors
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore sidebar scroll position after page load
|
|
function restoreSidebarScroll() {
|
|
if (sidebar && !isSmallScreen()) {
|
|
try {
|
|
const savedScroll = localStorage.getItem(SIDEBAR_SCROLL_KEY);
|
|
const lastUrl = localStorage.getItem(LAST_URL_KEY);
|
|
const currentUrl = window.location.pathname;
|
|
|
|
// Only restore if we're on the same section (admin pages, etc.)
|
|
// This prevents restoring scroll when navigating to completely different sections
|
|
if (savedScroll && lastUrl && currentUrl) {
|
|
// Check if we're navigating within the same section
|
|
const sameSection = (
|
|
(lastUrl.startsWith('/admin') && currentUrl.startsWith('/admin')) ||
|
|
(lastUrl.startsWith('/projects') && currentUrl.startsWith('/projects')) ||
|
|
(lastUrl.startsWith('/timer') && currentUrl.startsWith('/timer')) ||
|
|
(lastUrl.startsWith('/reports') && currentUrl.startsWith('/reports'))
|
|
);
|
|
|
|
if (sameSection) {
|
|
// Small delay to ensure DOM is ready
|
|
setTimeout(() => {
|
|
sidebar.scrollTop = parseInt(savedScroll, 10) || 0;
|
|
}, 50);
|
|
}
|
|
}
|
|
} catch(e) {
|
|
// Ignore localStorage errors
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save scroll position when clicking navigation links
|
|
if (sidebar) {
|
|
const navLinks = sidebar.querySelectorAll('a[href]');
|
|
navLinks.forEach(link => {
|
|
link.addEventListener('click', function() {
|
|
saveSidebarScroll();
|
|
});
|
|
});
|
|
}
|
|
|
|
// Restore scroll position on page load
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', restoreSidebarScroll);
|
|
} else {
|
|
restoreSidebarScroll();
|
|
}
|
|
})();
|
|
|
|
// Prevent unwanted scroll-to-top on navigation
|
|
(function() {
|
|
const MAIN_SCROLL_KEY = 'main-content-scroll-position';
|
|
const NAVIGATION_TYPE_KEY = 'navigation-type';
|
|
|
|
// Use browser's scroll restoration if available
|
|
if ('scrollRestoration' in history) {
|
|
history.scrollRestoration = 'manual';
|
|
}
|
|
|
|
// Save main content scroll position before navigation
|
|
function saveMainScroll() {
|
|
try {
|
|
const scrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop;
|
|
localStorage.setItem(MAIN_SCROLL_KEY, String(scrollY));
|
|
// Mark as programmatic navigation (not back/forward)
|
|
sessionStorage.setItem(NAVIGATION_TYPE_KEY, 'navigate');
|
|
} catch(e) {
|
|
// Ignore storage errors
|
|
}
|
|
}
|
|
|
|
// Restore main content scroll position after page load
|
|
function restoreMainScroll() {
|
|
try {
|
|
const navType = sessionStorage.getItem(NAVIGATION_TYPE_KEY);
|
|
const savedScroll = localStorage.getItem(MAIN_SCROLL_KEY);
|
|
|
|
// Only restore scroll for back/forward navigation or same-section navigation
|
|
// For fresh navigations, let browser handle it naturally
|
|
if (navType === 'back-forward' && savedScroll) {
|
|
// Restore scroll position for back/forward navigation
|
|
setTimeout(() => {
|
|
window.scrollTo(0, parseInt(savedScroll, 10) || 0);
|
|
}, 0);
|
|
} else if (navType === 'navigate' && savedScroll) {
|
|
// For same-section navigation, restore scroll
|
|
const lastUrl = localStorage.getItem('last-navigation-url');
|
|
const currentUrl = window.location.pathname;
|
|
|
|
if (lastUrl && currentUrl) {
|
|
const sameSection = (
|
|
(lastUrl.startsWith('/admin') && currentUrl.startsWith('/admin')) ||
|
|
(lastUrl.startsWith('/projects') && currentUrl.startsWith('/projects')) ||
|
|
(lastUrl.startsWith('/timer') && currentUrl.startsWith('/timer')) ||
|
|
(lastUrl.startsWith('/reports') && currentUrl.startsWith('/reports'))
|
|
);
|
|
|
|
if (sameSection) {
|
|
setTimeout(() => {
|
|
window.scrollTo(0, parseInt(savedScroll, 10) || 0);
|
|
}, 50);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear navigation type after processing
|
|
sessionStorage.removeItem(NAVIGATION_TYPE_KEY);
|
|
} catch(e) {
|
|
// Ignore storage errors
|
|
}
|
|
}
|
|
|
|
// Detect back/forward navigation
|
|
window.addEventListener('popstate', function() {
|
|
try {
|
|
sessionStorage.setItem(NAVIGATION_TYPE_KEY, 'back-forward');
|
|
} catch(e) {
|
|
// Ignore storage errors
|
|
}
|
|
});
|
|
|
|
// Save scroll position when clicking navigation links
|
|
document.addEventListener('click', function(e) {
|
|
const link = e.target.closest('a[href]');
|
|
if (link && link.href && !link.target && !link.hasAttribute('download')) {
|
|
const href = link.getAttribute('href');
|
|
// Only handle internal links
|
|
if (href && !href.startsWith('#') && !href.startsWith('javascript:') && !href.startsWith('mailto:') && !href.startsWith('tel:')) {
|
|
try {
|
|
const url = new URL(href, window.location.origin);
|
|
// Only save if it's a same-origin navigation
|
|
if (url.origin === window.location.origin) {
|
|
saveMainScroll();
|
|
}
|
|
} catch(e) {
|
|
// If URL parsing fails, try to save anyway for relative URLs
|
|
if (href.startsWith('/')) {
|
|
saveMainScroll();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Restore scroll position on page load
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', restoreMainScroll);
|
|
} else {
|
|
restoreMainScroll();
|
|
}
|
|
})();
|
|
|
|
// Flyout submenu when collapsed
|
|
function hideFlyout(){
|
|
if (flyout){ flyout.classList.add('hidden'); flyout.innerHTML=''; }
|
|
}
|
|
function ensureFlyout(){
|
|
if (!flyout){
|
|
flyout = document.createElement('div');
|
|
flyout.id = 'sidebarFlyout';
|
|
flyout.className = 'hidden fixed z-50 bg-card-light/95 dark:bg-card-dark/95 border border-border-light dark:border-border-dark rounded-2xl shadow-2xl p-2';
|
|
document.body.appendChild(flyout);
|
|
document.addEventListener('click', (e) => {
|
|
if (!flyout || flyout.classList.contains('hidden')) return;
|
|
const inside = flyout.contains(e.target);
|
|
const trigger = e.target.closest('[data-dropdown]');
|
|
if (!inside && !trigger) hideFlyout();
|
|
});
|
|
window.addEventListener('resize', hideFlyout);
|
|
window.addEventListener('scroll', function(e) {
|
|
if (flyout && !flyout.classList.contains('hidden') && (e.target === flyout || flyout.contains(e.target)))
|
|
return;
|
|
hideFlyout();
|
|
}, true);
|
|
}
|
|
}
|
|
function showFlyout(triggerBtn, listId){
|
|
ensureFlyout();
|
|
const list = document.getElementById(listId);
|
|
if (!list) return;
|
|
flyout.innerHTML = '';
|
|
const title = document.createElement('div');
|
|
title.className = 'flyout-title';
|
|
title.textContent = (triggerBtn.getAttribute('title') || triggerBtn.textContent || '').trim();
|
|
flyout.appendChild(title);
|
|
const ul = document.createElement('ul');
|
|
ul.className = 'space-y-1';
|
|
list.querySelectorAll('a').forEach((a) => {
|
|
const li = document.createElement('li');
|
|
const link = a.cloneNode(true);
|
|
const isActive = a.classList.contains('text-primary') || a.classList.contains('font-semibold') || a.getAttribute('aria-current') === 'page';
|
|
link.className = 'flyout-link text-text-light dark:text-text-dark';
|
|
if (isActive) link.classList.add('is-active');
|
|
li.appendChild(link);
|
|
ul.appendChild(li);
|
|
});
|
|
flyout.appendChild(ul);
|
|
const rect = triggerBtn.getBoundingClientRect();
|
|
const sbRect = sidebar.getBoundingClientRect();
|
|
const left = Math.min(sbRect.right + 12, window.innerWidth - flyout.offsetWidth - 12);
|
|
const top = Math.min(Math.max(12, rect.top), window.innerHeight - flyout.offsetHeight - 12);
|
|
flyout.style.left = left + 'px';
|
|
flyout.style.top = top + 'px';
|
|
flyout.classList.remove('hidden');
|
|
}
|
|
sidebar.addEventListener('click', function(e){
|
|
const btn = e.target.closest('[data-dropdown]');
|
|
if (!btn) return;
|
|
const listId = btn.getAttribute('data-dropdown');
|
|
const isCollapsed = appShell.classList.contains('sidebar-collapsed') || document.documentElement.classList.contains('sidebar-collapsed');
|
|
if (isCollapsed){
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
showFlyout(btn, listId);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
<!-- Keyboard Shortcuts Help Modal -->
|
|
{% include 'components/keyboard_shortcuts_help.html' %}
|
|
|
|
<!-- Global Command Palette Modal -->
|
|
{% include 'partials/_command_palette.html' %}
|
|
<script>
|
|
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
|
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
|
var themeToggleSystemIcon = document.getElementById('theme-toggle-system-icon');
|
|
var themeToggleBtn = document.getElementById('theme-toggle');
|
|
var themeDropdown = document.getElementById('themeDropdown');
|
|
|
|
function getEffectiveTheme() {
|
|
var stored = localStorage.getItem('color-theme');
|
|
if (stored === 'system' || !stored) {
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
}
|
|
return stored;
|
|
}
|
|
function updateThemeIcon() {
|
|
var stored = localStorage.getItem('color-theme') || 'system';
|
|
themeToggleDarkIcon.classList.add('hidden');
|
|
themeToggleLightIcon.classList.add('hidden');
|
|
if (themeToggleSystemIcon) themeToggleSystemIcon.classList.add('hidden');
|
|
if (stored === 'system') {
|
|
if (themeToggleSystemIcon) themeToggleSystemIcon.classList.remove('hidden');
|
|
else if (getEffectiveTheme() === 'dark') themeToggleLightIcon.classList.remove('hidden');
|
|
else themeToggleDarkIcon.classList.remove('hidden');
|
|
} else if (stored === 'dark') {
|
|
themeToggleLightIcon.classList.remove('hidden');
|
|
} else {
|
|
themeToggleDarkIcon.classList.remove('hidden');
|
|
}
|
|
}
|
|
function applyTheme(theme) {
|
|
if (theme === 'system') {
|
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
} else if (theme === 'dark') {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
var fpDark = document.getElementById('flatpickr-dark-theme');
|
|
if (fpDark) fpDark.media = document.documentElement.classList.contains('dark') ? 'all' : 'none';
|
|
updateThemeIcon();
|
|
}
|
|
updateThemeIcon();
|
|
|
|
if (themeToggleBtn && themeDropdown) {
|
|
themeToggleBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
themeDropdown.classList.toggle('hidden');
|
|
themeToggleBtn.setAttribute('aria-expanded', themeDropdown.classList.contains('hidden') ? 'false' : 'true');
|
|
});
|
|
document.addEventListener('click', function() {
|
|
themeDropdown.classList.add('hidden');
|
|
themeToggleBtn.setAttribute('aria-expanded', 'false');
|
|
});
|
|
themeDropdown.addEventListener('click', function(e) { e.stopPropagation(); });
|
|
}
|
|
document.querySelectorAll('.theme-option').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var newTheme = this.getAttribute('data-theme');
|
|
localStorage.setItem('color-theme', newTheme);
|
|
applyTheme(newTheme);
|
|
if (themeDropdown) themeDropdown.classList.add('hidden');
|
|
{% if current_user.is_authenticated %}
|
|
fetch('/api/theme', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}' },
|
|
body: JSON.stringify({ theme: newTheme })
|
|
}).then(function(r) { return r.ok ? r.json() : r.json().then(function(d) { throw new Error(d.error || 'Failed'); }); })
|
|
.then(function(d) { if (d.success) console.log('Theme saved:', d.theme); })
|
|
.catch(function(err) { console.error('Theme save failed:', err); });
|
|
{% endif %}
|
|
});
|
|
});
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
|
|
if (localStorage.getItem('color-theme') === 'system') applyTheme('system');
|
|
});
|
|
|
|
function toggleDropdown(id, event) {
|
|
if (event) {
|
|
event.stopPropagation(); // Prevent event bubbling for nested dropdowns
|
|
}
|
|
const dropdown = document.getElementById(id);
|
|
const isCurrentlyHidden = dropdown.classList.contains('hidden');
|
|
|
|
// Define nested dropdowns and their parent dropdowns
|
|
// All admin submenus should keep the parent adminDropdown open
|
|
// pdfDropdown is directly under adminDropdown so it opens without needing System Settings open
|
|
const nestedDropdowns = {
|
|
'pdfDropdown': 'adminDropdown',
|
|
'adminUserMgmtDropdown': 'adminDropdown',
|
|
'adminSettingsDropdown': 'adminDropdown',
|
|
'adminSecurityDropdown': 'adminDropdown',
|
|
'adminIntegrationsDropdown': 'adminDropdown',
|
|
'adminDataDropdown': 'adminDropdown',
|
|
'adminMaintenanceDropdown': 'adminDropdown',
|
|
'reportsDropdown': 'financeDropdown' // Reports is nested under Finance
|
|
};
|
|
|
|
// Prevent parent dropdowns from closing when clicking inside nested dropdowns
|
|
// Add click handlers to nested dropdowns to stop propagation
|
|
if (id in nestedDropdowns) {
|
|
const nestedDropdown = document.getElementById(id);
|
|
if (nestedDropdown) {
|
|
// Remove existing handler if any to avoid duplicates
|
|
nestedDropdown.removeEventListener('click', nestedDropdown._stopPropagationHandler);
|
|
// Add new handler
|
|
nestedDropdown._stopPropagationHandler = function(e) {
|
|
e.stopPropagation();
|
|
};
|
|
nestedDropdown.addEventListener('click', nestedDropdown._stopPropagationHandler);
|
|
}
|
|
}
|
|
|
|
// If this is a nested dropdown, don't close its parent
|
|
const parentDropdown = nestedDropdowns[id];
|
|
|
|
// Special handling for reportsDropdown: keep it open if already open and on a reports page
|
|
if (id === 'reportsDropdown' && !isCurrentlyHidden) {
|
|
// Check if we're on a reports page by checking the current URL
|
|
const currentPath = window.location.pathname;
|
|
const isReportsPage = currentPath.includes('/reports/') || currentPath.includes('/scheduled') || currentPath.includes('/builder');
|
|
if (isReportsPage) {
|
|
// Don't close if we're on a reports page
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Close all other top-level dropdowns in the sidebar (accordion behavior)
|
|
// Note: Nested dropdowns (like pdfDropdown) are not in this list, so they work independently
|
|
const allSidebarDropdowns = ['workDropdown', 'financeDropdown', 'adminDropdown', 'crmDropdown', 'toolsDropdown'];
|
|
|
|
// Build a list of all ancestor dropdowns that should stay open
|
|
const ancestorsToKeepOpen = [];
|
|
if (parentDropdown) {
|
|
ancestorsToKeepOpen.push(parentDropdown);
|
|
// Check if parent has a grandparent
|
|
const grandparent = nestedDropdowns[parentDropdown];
|
|
if (grandparent) {
|
|
ancestorsToKeepOpen.push(grandparent);
|
|
}
|
|
}
|
|
|
|
allSidebarDropdowns.forEach(dropdownId => {
|
|
// Don't close if this dropdown is an ancestor of the nested dropdown being opened
|
|
if (ancestorsToKeepOpen.includes(dropdownId)) {
|
|
return;
|
|
}
|
|
// Don't close adminDropdown if clicking on any of its submenus
|
|
if (dropdownId === 'adminDropdown' && parentDropdown === 'adminDropdown') {
|
|
return;
|
|
}
|
|
// Don't close financeDropdown if clicking on reportsDropdown
|
|
if (dropdownId === 'financeDropdown' && parentDropdown === 'financeDropdown') {
|
|
return;
|
|
}
|
|
if (dropdownId !== id) {
|
|
const otherDropdown = document.getElementById(dropdownId);
|
|
if (otherDropdown) {
|
|
otherDropdown.classList.add('hidden');
|
|
// Rotate chevron back for closed dropdowns
|
|
const otherBtn = document.querySelector(`[data-dropdown="${dropdownId}"]`);
|
|
if (otherBtn) {
|
|
const otherChevron = otherBtn.querySelector('.fa-chevron-down');
|
|
if (otherChevron) {
|
|
otherChevron.style.transform = 'rotate(0deg)';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Toggle the clicked dropdown
|
|
if (isCurrentlyHidden) {
|
|
dropdown.classList.remove('hidden');
|
|
// If this is a nested dropdown, ensure all ancestor dropdowns are open
|
|
if (parentDropdown) {
|
|
// Open the direct parent
|
|
const parent = document.getElementById(parentDropdown);
|
|
if (parent) {
|
|
parent.classList.remove('hidden');
|
|
}
|
|
// Check if parent has a grandparent and open it too
|
|
const grandparent = nestedDropdowns[parentDropdown];
|
|
if (grandparent) {
|
|
const grandparentDropdown = document.getElementById(grandparent);
|
|
if (grandparentDropdown) {
|
|
grandparentDropdown.classList.remove('hidden');
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
dropdown.classList.add('hidden');
|
|
}
|
|
|
|
// Rotate chevron icon for visual feedback
|
|
const btn = document.querySelector(`[data-dropdown="${id}"]`);
|
|
if (btn) {
|
|
const chevron = btn.querySelector('.fa-chevron-down');
|
|
if (chevron) {
|
|
chevron.style.transition = 'transform 0.2s ease';
|
|
chevron.style.transform = isCurrentlyHidden ? 'rotate(180deg)' : 'rotate(0deg)';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delegated event listeners (replaces inline onclick handlers)
|
|
document.addEventListener('click', function(e) {
|
|
// Handle dropdown toggles via data-dropdown attribute
|
|
var dropdownBtn = e.target.closest('[data-dropdown]');
|
|
if (dropdownBtn) {
|
|
toggleDropdown(dropdownBtn.getAttribute('data-dropdown'), e);
|
|
return;
|
|
}
|
|
// Prevent propagation for links/elements inside dropdown menus
|
|
var noPropEl = e.target.closest('[data-no-propagation]');
|
|
if (noPropEl) {
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
|
|
// Prevent parent dropdowns from closing when clicking inside nested dropdowns
|
|
// This handles clicks on links and other elements inside nested dropdowns
|
|
document.addEventListener('click', function(e) {
|
|
// Check if the click is inside a nested dropdown
|
|
const clickedInsideNested = e.target.closest('#pdfDropdown') ||
|
|
e.target.closest('#reportsDropdown') ||
|
|
e.target.closest('#adminUserMgmtDropdown') ||
|
|
e.target.closest('#adminSecurityDropdown') ||
|
|
e.target.closest('#adminIntegrationsDropdown') ||
|
|
e.target.closest('#adminDataDropdown') ||
|
|
e.target.closest('#adminMaintenanceDropdown');
|
|
|
|
if (clickedInsideNested) {
|
|
// Find the parent dropdown and keep it open
|
|
const nestedDropdowns = {
|
|
'pdfDropdown': 'adminDropdown',
|
|
'adminUserMgmtDropdown': 'adminDropdown',
|
|
'adminSettingsDropdown': 'adminDropdown',
|
|
'adminSecurityDropdown': 'adminDropdown',
|
|
'adminIntegrationsDropdown': 'adminDropdown',
|
|
'adminDataDropdown': 'adminDropdown',
|
|
'adminMaintenanceDropdown': 'adminDropdown',
|
|
'reportsDropdown': 'financeDropdown'
|
|
};
|
|
|
|
// Find which nested dropdown was clicked
|
|
let clickedDropdownId = null;
|
|
for (const [nestedId, parentId] of Object.entries(nestedDropdowns)) {
|
|
if (e.target.closest('#' + nestedId)) {
|
|
clickedDropdownId = nestedId;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (clickedDropdownId && nestedDropdowns[clickedDropdownId]) {
|
|
const parentId = nestedDropdowns[clickedDropdownId];
|
|
const parentDropdown = document.getElementById(parentId);
|
|
if (parentDropdown) {
|
|
parentDropdown.classList.remove('hidden');
|
|
}
|
|
// Also keep adminDropdown open if parent is adminSettingsDropdown
|
|
if (parentId === 'adminSettingsDropdown') {
|
|
const adminDropdown = document.getElementById('adminDropdown');
|
|
if (adminDropdown) {
|
|
adminDropdown.classList.remove('hidden');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, true); // Use capture phase to catch events early
|
|
</script>
|
|
|
|
<!-- Enhanced UI Scripts -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
<script src="{{ url_for('static', filename='charts.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='enhanced-ui.js') }}"></script>
|
|
<!-- Onboarding: onboarding.js = core tour runner + restartTour(); onboarding-enhanced.js = enhanced steps/tooltips. Use restartTour() or window.onboardingManager to restart tour. -->
|
|
<script src="{{ url_for('static', filename='onboarding.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='onboarding-enhanced.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='error-handling-enhanced.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='ui-enhancements.js') }}"></script>
|
|
|
|
<!-- Shared utilities (must load before keyboard shortcuts) -->
|
|
<script src="{{ url_for('static', filename='typing-utils.js') }}"></script>
|
|
{% if keyboard_shortcuts_config %}
|
|
<script>window.__KEYBOARD_SHORTCUTS_CONFIG__ = {{ keyboard_shortcuts_config | tojson }};</script>
|
|
{% endif %}
|
|
<!-- Advanced Features -->
|
|
<script src="{{ url_for('static', filename='keyboard-shortcuts-advanced.js') }}?v=2.2"></script>
|
|
<script src="{{ url_for('static', filename='smart-notifications.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='dashboard-widgets.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='dashboard-enhancements.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='activity-feed.js') }}"></script>
|
|
|
|
<script>
|
|
// Global helpers for bulk menus and confirmation using custom modal (no native confirm)
|
|
window.showConfirm = function(message, opts){
|
|
try {
|
|
const options = Object.assign({
|
|
title: '',
|
|
confirmText: 'Confirm',
|
|
cancelText: 'Cancel',
|
|
variant: 'primary' // 'primary' | 'danger' | 'warning'
|
|
}, opts || {});
|
|
return new Promise((resolve) => {
|
|
// Build overlay
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'fixed inset-0 z-[2000] flex items-center justify-center';
|
|
overlay.innerHTML = `
|
|
<div class="absolute inset-0 bg-black/50" data-close></div>
|
|
<div class="relative bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-xl w-full max-w-md mx-4">
|
|
<div class="p-6">
|
|
<div class="flex items-start gap-3">
|
|
<div class="w-12 h-12 rounded-full ${options.variant==='danger' ? 'bg-rose-100 dark:bg-rose-900/30' : (options.variant==='warning' ? 'bg-amber-100 dark:bg-amber-900/30' : 'bg-sky-100 dark:bg-sky-900/30')} flex items-center justify-center flex-shrink-0">
|
|
<i class="fas fa-exclamation-triangle ${options.variant==='danger' ? 'text-rose-600 dark:text-rose-400' : (options.variant==='warning' ? 'text-amber-600 dark:text-amber-400' : 'text-sky-600 dark:text-sky-400')}"></i>
|
|
</div>
|
|
<div class="flex-1">
|
|
${options.title ? `<h3 class="text-lg font-semibold mb-1">${options.title}</h3>` : ''}
|
|
<p class="text-sm">${message || ''}</p>
|
|
</div>
|
|
</div>
|
|
<div class="mt-6 flex justify-end gap-3">
|
|
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg" data-cancel>${options.cancelText}</button>
|
|
<button type="button" class="px-4 py-2 ${options.variant==='danger' ? 'bg-rose-600 hover:bg-rose-700' : (options.variant==='warning' ? 'bg-amber-500 hover:bg-amber-600' : 'bg-primary hover:bg-primary/90')} text-white rounded-lg" data-confirm>${options.confirmText}</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
function cleanup(result){
|
|
try { document.body.removeChild(overlay); } catch(_) {}
|
|
resolve(result);
|
|
}
|
|
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target.hasAttribute('data-close')) cleanup(false);
|
|
if (e.target.hasAttribute('data-cancel')) cleanup(false);
|
|
if (e.target.hasAttribute('data-confirm')) cleanup(true);
|
|
});
|
|
document.addEventListener('keydown', function onKey(e){
|
|
if (e.key === 'Escape'){ cleanup(false); document.removeEventListener('keydown', onKey); }
|
|
if (e.key === 'Enter'){ cleanup(true); document.removeEventListener('keydown', onKey); }
|
|
});
|
|
document.body.appendChild(overlay);
|
|
// Focus confirm button
|
|
setTimeout(() => { try { overlay.querySelector('[data-confirm]').focus(); } catch(_) {} }, 0);
|
|
});
|
|
} catch(_) {
|
|
// Absolute fallback if anything goes wrong
|
|
try { return Promise.resolve(window.confirm(message)); } catch(__) { return Promise.resolve(false); }
|
|
}
|
|
};
|
|
window.showAlert = window.showAlert || function(message){
|
|
try { window.alert(message); } catch(_) {}
|
|
};
|
|
function closeAllMenus(){
|
|
try { document.querySelectorAll('.bulk-menu').forEach(m => m.classList.add('hidden')); } catch(_) {}
|
|
}
|
|
function openMenu(triggerEl, menuId){
|
|
try{
|
|
const menu = document.getElementById(menuId);
|
|
if (!menu) return;
|
|
const willOpen = menu.classList.contains('hidden');
|
|
closeAllMenus();
|
|
if (!willOpen) return;
|
|
// Reset positioning
|
|
menu.style.top = '';
|
|
menu.style.bottom = '';
|
|
menu.style.maxHeight = '16rem';
|
|
// Temporarily show off-screen to measure accurately
|
|
const originalDisplay = menu.style.display;
|
|
menu.style.visibility = 'hidden';
|
|
menu.style.display = 'block';
|
|
const rect = triggerEl.getBoundingClientRect();
|
|
const menuHeight = menu.scrollHeight || menu.offsetHeight || 200;
|
|
const spaceBelow = window.innerHeight - rect.bottom;
|
|
const spaceAbove = rect.top;
|
|
// Restore visibility before final placement
|
|
menu.style.display = originalDisplay || '';
|
|
menu.style.visibility = '';
|
|
// Flip to dropup if not enough space below
|
|
const needsDropup = spaceBelow < Math.min(menuHeight, 256) + 16 && spaceAbove > spaceBelow;
|
|
if (needsDropup) { menu.style.bottom = 'calc(100% + 8px)'; } else { menu.style.top = 'calc(100% + 8px)'; }
|
|
menu.classList.remove('hidden');
|
|
} catch(_) {}
|
|
}
|
|
// Click outside to close any bulk menus
|
|
document.addEventListener('click', function(e){
|
|
const trigger = e.target.closest('#bulkActionsBtn');
|
|
const insideAnyMenu = e.target.closest('.bulk-menu');
|
|
if (!trigger && !insideAnyMenu){ closeAllMenus(); }
|
|
});
|
|
// Close on Escape
|
|
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeAllMenus(); });
|
|
|
|
// Support Banner Logic with Smart Prompts
|
|
function dismissSupportBanner() {
|
|
const banner = document.getElementById('supportBanner');
|
|
if (banner) {
|
|
banner.classList.add('opacity-0', 'invisible', 'max-h-0', 'overflow-hidden');
|
|
banner.classList.remove('opacity-100', 'visible', 'max-h-[100px]');
|
|
// Store dismissal timestamp (show again after 30 days)
|
|
try {
|
|
localStorage.setItem('supportBannerDismissed', Date.now().toString());
|
|
// Track dismissal
|
|
trackBannerDismissal();
|
|
} catch(e) {}
|
|
}
|
|
}
|
|
|
|
function trackBannerImpression(source) {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
fetch('{{ url_for("main.track_support_impression") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ source: source || 'banner', variant: window.supportAbVariant || 'control' })
|
|
}).catch(function() {});
|
|
}
|
|
|
|
function trackBannerDismissal() {
|
|
// Get CSRF token from meta tag
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
|
|
fetch('{{ url_for("main.track_banner_dismissal") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ variant: window.supportAbVariant || 'control' })
|
|
}).catch(() => {
|
|
// Silently fail if tracking doesn't work
|
|
});
|
|
}
|
|
|
|
function trackDonationClick(source) {
|
|
// Get CSRF token from meta tag
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
|
|
fetch('{{ url_for("main.track_donation_click") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({
|
|
source: source
|
|
})
|
|
}).catch(() => {
|
|
// Silently fail if tracking doesn't work
|
|
});
|
|
}
|
|
|
|
function shouldShowSupportBanner() {
|
|
try {
|
|
// Check if dismissed recently (30 days)
|
|
const dismissed = localStorage.getItem('supportBannerDismissed');
|
|
if (dismissed) {
|
|
const dismissedTime = parseInt(dismissed);
|
|
const thirtyDays = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
|
|
if ((Date.now() - dismissedTime) < thirtyDays) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check if user clicked donation link recently (30 days)
|
|
const lastClick = localStorage.getItem('donationLinkClicked');
|
|
if (lastClick) {
|
|
const clickTime = parseInt(lastClick);
|
|
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
|
|
if ((Date.now() - clickTime) < thirtyDays) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch(e) {
|
|
return true; // Show by default if localStorage fails
|
|
}
|
|
}
|
|
|
|
function updateBannerMessage() {
|
|
// Get user stats from page if available
|
|
const bannerTitle = document.getElementById('bannerTitle');
|
|
const bannerMessage = document.getElementById('bannerMessage');
|
|
|
|
if (!bannerTitle || !bannerMessage) return;
|
|
|
|
// Try to get user stats from data attributes or API
|
|
const userStats = window.userStats || {};
|
|
const daysSinceSignup = userStats.days_since_signup || 0;
|
|
const timeEntriesCount = userStats.time_entries_count || 0;
|
|
const totalHours = userStats.total_hours || 0;
|
|
|
|
// Smart messaging based on milestones
|
|
if (totalHours >= 100) {
|
|
bannerTitle.textContent = '{{ _("Amazing! You\'ve tracked over 100 hours") }}';
|
|
bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕';
|
|
} else if (timeEntriesCount >= 50) {
|
|
bannerTitle.textContent = '{{ _("Great progress! You\'ve logged 50+ entries") }}';
|
|
bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕';
|
|
} else if (daysSinceSignup >= 7) {
|
|
bannerTitle.textContent = '{{ _("Thanks for using TimeTracker!") }}';
|
|
bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕';
|
|
} else {
|
|
bannerTitle.textContent = '{{ _("Enjoying TimeTracker?") }}';
|
|
bannerMessage.textContent = '{{ _("Support updates and new features — or remove prompts with a key") }} ☕';
|
|
}
|
|
}
|
|
|
|
// Show support banner if conditions are met
|
|
// Check immediately to reserve space and prevent layout shift
|
|
(function() {
|
|
const banner = document.getElementById('supportBanner');
|
|
if (!banner) return;
|
|
// Server-side suppression: don't show if user clicked a support CTA in last 30 days
|
|
if ({{ 'true' if support_banner_suppressed else 'false' }}) { return; }
|
|
|
|
if (shouldShowSupportBanner()) {
|
|
// Update banner message based on user stats
|
|
updateBannerMessage();
|
|
|
|
// Reserve space immediately by removing height constraints
|
|
// This prevents layout shift when banner becomes visible
|
|
banner.classList.remove('max-h-0', 'overflow-hidden');
|
|
// Space is now reserved, content just invisible
|
|
|
|
// Show banner with a slight delay for better UX, but space is already reserved
|
|
setTimeout(() => {
|
|
banner.classList.remove('opacity-0', 'invisible');
|
|
banner.classList.add('opacity-100', 'visible');
|
|
// Track banner impression once per page load (for funnel metrics)
|
|
if (!window._supportBannerImpressionTracked) {
|
|
window._supportBannerImpressionTracked = true;
|
|
trackBannerImpression('banner');
|
|
}
|
|
}, 2000); // Show after 2 seconds
|
|
} else {
|
|
// Keep it hidden and collapsed - no space reserved
|
|
banner.classList.add('max-h-0', 'overflow-hidden');
|
|
}
|
|
})();
|
|
|
|
// Track donation link clicks
|
|
document.addEventListener('click', function(e) {
|
|
const link = e.target.closest('a[href*="buymeacoffee.com"], a[href*="paypal.com/donate"]');
|
|
if (link) {
|
|
try {
|
|
localStorage.setItem('donationLinkClicked', Date.now().toString());
|
|
} catch(e) {}
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<script src="{{ url_for('static', filename='data-tables-enhanced.js') }}"></script>
|
|
|
|
<!-- User Stats for Smart Banner -->
|
|
{% if current_user.is_authenticated %}
|
|
<script>
|
|
window.userStats = {
|
|
days_since_signup: {{ user_stats.days_since_signup if user_stats else 0 }},
|
|
time_entries_count: {{ user_stats.time_entries_count if user_stats else 0 }},
|
|
total_hours: {{ user_stats.total_hours if user_stats else 0.0 }}
|
|
};
|
|
</script>
|
|
{% endif %}
|
|
|
|
|
|
<!-- Global donation tracking and A/B variant -->
|
|
<script>
|
|
window.supportAbVariant = {{ (support_ab_variant or 'control')|tojson }};
|
|
function trackDonationClick(source) {
|
|
{% if current_user.is_authenticated %}
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
fetch('{{ url_for("main.track_donation_click") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ source: source, variant: window.supportAbVariant || 'control' })
|
|
}).catch(function() {});
|
|
{% endif %}
|
|
}
|
|
</script>
|
|
|
|
{% block scripts_extra %}{% endblock %}
|
|
{% include 'partials/_bottom_nav.html' %}
|
|
<script>
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('{{ url_for("main.service_worker") }}', { updateViaCache: 'none' }).catch(function () {});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|