mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-30 07:40:51 -06:00
- Add session state clearing (expunge_all) after rollbacks in custom field definition error handlers to prevent stale session state - Add graceful error handling for missing link_templates table with proper rollback and session cleanup, preventing app crashes when migrations haven't been run - Add detailed performance logging to TaskService.list_tasks method to track timing of each query step for performance monitoring - Improve PWA install prompt UI with better toast integration, dismiss button, and proper DOM manipulation using requestAnimationFrame - Bump version to 4.5.0
1708 lines
126 KiB
HTML
1708 lines
126 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">
|
||
<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="#3b82f6">
|
||
<!-- Favicon -->
|
||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/timetracker-logo.svg') }}">
|
||
<link rel="alternate icon" href="{{ url_for('static', filename='images/timetracker-logo.svg') }}">
|
||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/timetracker-logo.svg') }}">
|
||
<!--
|
||
PostHog Session Replay Privacy Protection:
|
||
Add the CSS class 'ph-no-capture' to any element containing sensitive data
|
||
to prevent it from being recorded in session replays.
|
||
|
||
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>
|
||
|
||
PostHog will mask these elements automatically in recordings.
|
||
For more details, see: POSTHOG_SESSION_REPLAY_PRIVACY.md
|
||
-->
|
||
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
||
<meta name="vapid-public-key" content="{{ config.get('VAPID_PUBLIC_KEY', '') }}">
|
||
<script src="{{ url_for('static', filename='pwa-enhancements.js') }}"></script>
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='dist/output.css') }}">
|
||
<!-- 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='enhanced-ui.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='data-tables-enhanced.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='keyboard-shortcuts.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='ui-enhancements.css') }}">
|
||
<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: 1024px) {
|
||
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 #workDropdown, .sidebar-collapsed #insightsDropdown { display: none; }
|
||
.sidebar-collapsed #sidebar { width: 4rem !important; overflow-x: hidden; }
|
||
/* 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%;
|
||
}
|
||
#mainContentAnchor {
|
||
flex: 1 0 auto;
|
||
min-height: 0;
|
||
width: 100%;
|
||
overflow-x: auto;
|
||
}
|
||
/* Ensure body and html don't cause horizontal overflow */
|
||
html, body { overflow-x: hidden; }
|
||
#appShell { overflow-x: hidden; width: 100%; }
|
||
|
||
/* 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);
|
||
}
|
||
</style>
|
||
<script>
|
||
// Theme init (unchanged)
|
||
{% if current_user.is_authenticated and current_user.theme %}
|
||
var userTheme = '{{ current_user.theme }}';
|
||
if (userTheme === 'dark') {
|
||
document.documentElement.classList.add('dark');
|
||
localStorage.setItem('color-theme', 'dark');
|
||
} else if (userTheme === 'light') {
|
||
document.documentElement.classList.remove('dark');
|
||
localStorage.setItem('color-theme', 'light');
|
||
} else if (userTheme === 'system') {
|
||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||
document.documentElement.classList.add('dark');
|
||
} else {
|
||
document.documentElement.classList.remove('dark');
|
||
}
|
||
}
|
||
{% else %}
|
||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||
document.documentElement.classList.add('dark');
|
||
} else {
|
||
document.documentElement.classList.remove('dark');
|
||
}
|
||
{% endif %}
|
||
</script>
|
||
|
||
<!-- i18n translations for JavaScript -->
|
||
<script>
|
||
window.i18n = {
|
||
toast: {
|
||
success: '{{ _("Success") }}',
|
||
error: '{{ _("Error") }}',
|
||
warning: '{{ _("Warning") }}',
|
||
info: '{{ _("Information") }}'
|
||
},
|
||
common: {
|
||
loading: '{{ _("Loading...") }}',
|
||
saving: '{{ _("Saving...") }}',
|
||
deleting: '{{ _("Deleting...") }}',
|
||
cancel: '{{ _("Cancel") }}',
|
||
confirm: '{{ _("Confirm") }}',
|
||
close: '{{ _("Close") }}',
|
||
save: '{{ _("Save") }}',
|
||
delete: '{{ _("Delete") }}',
|
||
edit: '{{ _("Edit") }}',
|
||
add: '{{ _("Add") }}',
|
||
remove: '{{ _("Remove") }}',
|
||
yes: '{{ _("Yes") }}',
|
||
no: '{{ _("No") }}',
|
||
ok: '{{ _("OK") }}'
|
||
},
|
||
messages: {
|
||
confirmDelete: '{{ _("Are you sure you want to delete this?") }}',
|
||
unsavedChanges: '{{ _("You have unsaved changes. Are you sure you want to leave?") }}',
|
||
operationFailed: '{{ _("Operation failed") }}',
|
||
operationSuccess: '{{ _("Operation completed successfully") }}',
|
||
noItemsSelected: '{{ _("No items selected") }}',
|
||
invalidInput: '{{ _("Invalid input") }}',
|
||
requiredField: '{{ _("This field is required") }}',
|
||
noActiveTimer: '{{ _("No active timer") }}',
|
||
timerStopped: '{{ _("Timer stopped") }}',
|
||
timerStopFailed: '{{ _("Failed to stop timer") }}',
|
||
errorStoppingTimer: '{{ _("Error stopping timer") }}',
|
||
noFormToSave: '{{ _("No form to save") }}',
|
||
noTimerFound: '{{ _("No timer found") }}',
|
||
timerStoppedInactivity: '{{ _("Timer stopped due to inactivity") }}',
|
||
selectProjectOrClient: '{{ _("Please select either a project or a client") }}',
|
||
endTimeAfterStartTime: '{{ _("End time must be after start time") }}'
|
||
}
|
||
};
|
||
</script>
|
||
|
||
{% block extra_css %}{% endblock %}
|
||
</head>
|
||
<body class="bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
|
||
<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="w-64 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark p-4 flex-col hidden lg:flex transition-all duration-200 ease-in-out fixed top-0 left-0 h-screen overflow-y-auto z-10">
|
||
<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('timer.') or ep.startswith('kanban.') or ep.startswith('weekly_goals.') or ep.startswith('project_templates.') or ep.startswith('gantt.') %}
|
||
{% 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.') %}
|
||
{% 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.') %}
|
||
{% 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.startswith('admin.') and ('email_template' in ep or 'email-templates' in request.path) or ep == 'admin.pdf_layout' or ep == 'admin.quote_pdf_layout' or ep == 'admin.oidc_debug' %}
|
||
{% set admin_security_open = ep == 'admin.api_tokens' or ep.startswith('webhooks.') or ep.startswith('audit_logs.') %}
|
||
{% set admin_integrations_open = ep == 'admin.list_integrations_admin' %}
|
||
{% 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-4">
|
||
<h2 class="text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider sidebar-label">{{ _('Navigation') }}</h2>
|
||
<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="flex items-center p-2 rounded-lg {% if ep == 'main.dashboard' %}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-tachometer-alt w-6 text-center"></i>
|
||
<span class="ml-3 sidebar-label">{{ _('Dashboard') }}</span>
|
||
</a>
|
||
</li>
|
||
{% if (not settings or settings.ui_allow_calendar) and current_user.ui_show_calendar %}
|
||
<li class="mt-2">
|
||
<button onclick="toggleDropdown('calendarDropdown')" data-dropdown="calendarDropdown" class="w-full flex items-center p-2 rounded-lg {% if calendar_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-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-2">
|
||
<button onclick="toggleDropdown('workDropdown')" data-dropdown="workDropdown" class="w-full flex items-center p-2 rounded-lg {% if work_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-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_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_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.') %}
|
||
<li>
|
||
<a class="block px-2 py-1 rounded {% if nav_active_timer %}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('timer.manual_entry') }}">
|
||
<i class="fas fa-clock w-4 mr-2"></i>{{ _('Log Time') }}
|
||
</a>
|
||
</li>
|
||
<li>
|
||
<a class="block px-2 py-1 rounded {% if nav_active_time_entries %}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('timer.time_entries_overview') }}">
|
||
<i class="fas fa-list-alt w-4 mr-2"></i>{{ _('Time Entries') }}
|
||
</a>
|
||
</li>
|
||
<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 settings.ui_allow_project_templates and current_user.ui_show_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 settings.ui_allow_gantt_chart and current_user.ui_show_gantt_chart %}
|
||
<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 settings.ui_allow_kanban_board and current_user.ui_show_kanban_board %}
|
||
<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 settings.ui_allow_weekly_goals and current_user.ui_show_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 %}
|
||
</ul>
|
||
</li>
|
||
<li class="mt-2">
|
||
<button onclick="toggleDropdown('crmDropdown')" data-dropdown="crmDropdown" class="w-full flex items-center p-2 rounded-lg {% if crm_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-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.') %}
|
||
<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>
|
||
{% if settings.ui_allow_quotes and current_user.ui_show_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>
|
||
<li class="mt-2">
|
||
<button onclick="toggleDropdown('financeDropdown')" data-dropdown="financeDropdown" class="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 settings.ui_allow_reports and current_user.ui_show_reports %}
|
||
<li>
|
||
<button onclick="toggleDropdown('reportsDropdown', event)" 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 onclick="event.stopPropagation();" 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 settings.ui_allow_report_builder and current_user.ui_show_report_builder %}
|
||
<li>
|
||
<a onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 settings.ui_allow_scheduled_reports and current_user.ui_show_scheduled_reports %}
|
||
<li>
|
||
<a onclick="event.stopPropagation();" 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 %}
|
||
<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>
|
||
{% if settings.ui_allow_invoice_approvals and current_user.ui_show_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 settings.ui_allow_payment_gateways and current_user.ui_show_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 settings.ui_allow_recurring_invoices and current_user.ui_show_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 settings.ui_allow_payments and current_user.ui_show_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 %}
|
||
<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>
|
||
{% if settings.ui_allow_mileage and current_user.ui_show_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 settings.ui_allow_per_diem and current_user.ui_show_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 settings.ui_allow_budget_alerts and current_user.ui_show_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>
|
||
{% if settings.ui_allow_inventory and current_user.ui_show_inventory %}
|
||
<li class="mt-2">
|
||
<button onclick="toggleDropdown('inventoryDropdown')" data-dropdown="inventoryDropdown" class="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 settings.ui_allow_analytics and current_user.ui_show_analytics %}
|
||
<li class="mt-2">
|
||
<a href="{{ url_for('analytics.analytics_dashboard') }}" class="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 settings.ui_allow_tools and current_user.ui_show_tools %}
|
||
<li class="mt-2">
|
||
<button onclick="toggleDropdown('toolsDropdown')" data-dropdown="toolsDropdown" class="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.') %}
|
||
<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>
|
||
<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>
|
||
<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>
|
||
</ul>
|
||
</li>
|
||
{% endif %}
|
||
{% if current_user.is_admin or has_any_permission(['view_users', 'manage_settings', 'view_system_info', 'manage_backups']) %}
|
||
<li class="mt-2">
|
||
<button onclick="toggleDropdown('adminDropdown')" data-dropdown="adminDropdown" class="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 -->
|
||
<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>
|
||
|
||
<!-- User Management Submenu -->
|
||
{% if current_user.is_admin or has_permission('view_users') %}
|
||
<li>
|
||
<button onclick="toggleDropdown('adminUserMgmtDropdown', event)" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 %}
|
||
|
||
<!-- System Settings Submenu -->
|
||
{% if current_user.is_admin or has_permission('manage_settings') or has_permission('manage_oidc') %}
|
||
<li>
|
||
<button onclick="toggleDropdown('adminSettingsDropdown', event)" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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>
|
||
<li>
|
||
<button onclick="toggleDropdown('pdfDropdown', event)" 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">
|
||
<li>
|
||
<a onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 %}
|
||
{% if current_user.is_admin or has_permission('manage_oidc') %}
|
||
<li>
|
||
<a onclick="event.stopPropagation();" 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 onclick="toggleDropdown('adminSecurityDropdown', event)" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 %}
|
||
|
||
<!-- Integrations Submenu -->
|
||
{% if current_user.is_admin %}
|
||
<li>
|
||
<button onclick="toggleDropdown('adminIntegrationsDropdown', event)" data-dropdown="adminIntegrationsDropdown" class="w-full flex items-center px-2 py-1 rounded {% if admin_integrations_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-plug w-4 mr-2"></i>
|
||
<span class="flex-1 text-left">{{ _('Integrations') }}</span>
|
||
<i class="fas fa-chevron-down text-xs"></i>
|
||
</button>
|
||
<ul id="adminIntegrationsDropdown" class="{% if admin_integrations_open %}{% else %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
||
<li>
|
||
<a onclick="event.stopPropagation();" class="block px-2 py-1 rounded {% if ep == 'admin.list_integrations_admin' %}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_integrations_admin') }}">
|
||
<i class="fas fa-plug w-4 mr-2"></i>{{ _('Integrations') }}
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</li>
|
||
{% endif %}
|
||
|
||
<!-- Data Management Submenu -->
|
||
{% if current_user.is_admin or has_permission('manage_settings') %}
|
||
<li>
|
||
<button onclick="toggleDropdown('adminDataDropdown', event)" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 onclick="toggleDropdown('adminMaintenanceDropdown', event)" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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 onclick="event.stopPropagation();" 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="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="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>
|
||
<li class="mt-2">
|
||
<a href="https://buymeacoffee.com/DryTrix" target="_blank" rel="noopener noreferrer" class="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">
|
||
<i class="fas fa-mug-saucer w-6 text-center group-hover:scale-110 transition-transform"></i>
|
||
<span class="ml-3 sidebar-label font-medium">{{ _('Buy me a coffee') }}</span>
|
||
<span class="ml-auto text-xs opacity-70">☕</span>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
<!-- App Version -->
|
||
<div class="mt-4 pt-3 border-t border-border-light dark:border-border-dark">
|
||
<div class="flex items-center justify-center px-2 py-1 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||
<i class="fas fa-code-branch w-4 text-center"></i>
|
||
<span class="ml-2 sidebar-label">v{{ app_version }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main content -->
|
||
<div id="mainContent" class="flex-1 flex flex-col min-h-screen transition-all duration-200 ease-in-out lg:ml-64">
|
||
<!-- Header -->
|
||
<header class="bg-card-light dark:bg-card-dark p-4 border-b border-border-light dark:border-border-dark flex justify-between items-center">
|
||
<!-- Mobile menu button -->
|
||
<button id="mobileSidebarBtn" class="lg:hidden" aria-label="Open sidebar">
|
||
<i class="fas fa-bars"></i>
|
||
</button>
|
||
|
||
<!-- Search + Command button -->
|
||
<div class="hidden sm:flex items-center gap-2">
|
||
<div class="w-96">
|
||
<form class="navbar-search" role="search" action="{{ url_for('main.search') }}" method="get" autocomplete="off">
|
||
<label for="search" class="sr-only">{{ _('Search') }}</label>
|
||
<input id="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-14 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>
|
||
</div>
|
||
<button type="button" class="text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5" aria-label="Open command palette" onclick="window.openCommandPalette && window.openCommandPalette();">
|
||
<i class="fa-solid fa-terminal"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Right side controls -->
|
||
<div class="flex items-center space-x-4">
|
||
<!-- BuyMeACoffee Button -->
|
||
<a href="https://buymeacoffee.com/DryTrix"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="hidden md:flex items-center gap-2 px-3 py-1.5 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 text-sm font-medium"
|
||
title="{{ _('Support TimeTracker development') }}">
|
||
<i class="fas fa-mug-saucer"></i>
|
||
<span class="hidden lg:inline">{{ _('Support') }}</span>
|
||
</a>
|
||
|
||
<button id="theme-toggle" type="button" class="text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5" aria-label="{{ _('Toggle dark mode') }}">
|
||
<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>
|
||
</button>
|
||
|
||
<!-- Language Switcher -->
|
||
<div class="relative z-50">
|
||
<button onclick="toggleDropdown('langDropdown')" class="flex items-center text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5" 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 onclick="toggleDropdown('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>
|
||
{% 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>
|
||
<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>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- 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">
|
||
{{ _('Enjoying TimeTracker?') }}
|
||
</p>
|
||
<p class="text-xs text-amber-700 dark:text-amber-300">
|
||
{{ _('Support continued development with a coffee') }} ☕
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<a href="https://buymeacoffee.com/DryTrix"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||
{{ _('Support') }}
|
||
</a>
|
||
<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>
|
||
|
||
<!-- Main page content -->
|
||
<main id="mainContentAnchor" class="flex-1 p-6">
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Mobile Bottom Navigation -->
|
||
<nav class="lg:hidden fixed bottom-0 inset-x-0 bg-card-light dark:bg-card-dark border-t border-border-light dark:border-border-dark flex justify-around">
|
||
<a href="{{ url_for('main.dashboard') }}" class="flex flex-col items-center p-2 text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||
<i class="fas fa-tachometer-alt"></i>
|
||
<span class="text-xs">{{ _('Dashboard') }}</span>
|
||
</a>
|
||
<a href="{{ url_for('projects.list_projects') }}" class="flex flex-col items-center p-2 text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||
<i class="fas fa-briefcase"></i>
|
||
<span class="text-xs">{{ _('Projects') }}</span>
|
||
</a>
|
||
<a href="{{ url_for('timer.manual_entry') }}" class="flex flex-col items-center p-2 text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||
<i class="fas fa-plus-circle text-2xl"></i>
|
||
</a>
|
||
<a href="{{ url_for('reports.reports') }}" class="flex flex-col items-center p-2 text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||
<i class="fas fa-chart-line"></i>
|
||
<span class="text-xs">{{ _('Reports') }}</span>
|
||
</a>
|
||
<a href="{{ url_for('auth.profile') }}" class="flex flex-col items-center p-2 text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||
<i class="fas fa-user-circle"></i>
|
||
<span class="text-xs">{{ _('Profile') }}</span>
|
||
</a>
|
||
</nav>
|
||
|
||
</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>
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
|
||
<!-- 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') }}"></script>
|
||
<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>
|
||
<!-- Old command palette and keyboard navigation (restored) -->
|
||
<script src="{{ url_for('static', filename='commands.js') }}?v=2.0"></script>
|
||
<script>
|
||
// Minimal global shortcuts: Ctrl+/ (focus search), Ctrl+Shift+L (toggle theme), 't' (toggle timer)
|
||
// Note: Ctrl+K is handled by keyboard-shortcuts-advanced.js for command palette
|
||
(function(){
|
||
function isTyping(e){
|
||
const t = e.target;
|
||
const tag = (t && t.tagName || '').toLowerCase();
|
||
|
||
// Check standard inputs
|
||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || (t && t.isContentEditable)) {
|
||
return true;
|
||
}
|
||
|
||
// Check for rich text editors (Toast UI Editor, etc.)
|
||
const editorSelectors = ['.toastui-editor', '.toastui-editor-contents', '.ProseMirror', '.CodeMirror', '.ql-editor', '.tox-edit-area', '.note-editable', '[contenteditable="true"]', '.toastui-editor-ww-container', '.toastui-editor-md-container'];
|
||
for (let i = 0; i < editorSelectors.length; i++) {
|
||
if (t.closest && t.closest(editorSelectors[i])) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
document.addEventListener('keydown', (e) => {
|
||
// Ctrl/Cmd + / -> focus search (removed Ctrl+K to avoid conflict with command palette)
|
||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key === '/') {
|
||
e.preventDefault();
|
||
const search = document.getElementById('search');
|
||
if (search) {
|
||
search.focus();
|
||
if (search.select) search.select();
|
||
}
|
||
}
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
// Ctrl/Cmd + Shift + L -> toggle theme
|
||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'l' || e.key === 'L')) {
|
||
e.preventDefault();
|
||
document.getElementById('theme-toggle')?.click();
|
||
}
|
||
});
|
||
async function toggleTimer(){
|
||
try {
|
||
const statusRes = await fetch("{{ url_for('timer.timer_status') }}", { credentials: 'same-origin' });
|
||
const status = await statusRes.json();
|
||
if (status && status.active) {
|
||
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||
const stopRes = await fetch("{{ url_for('timer.stop_timer') }}", { method: 'POST', headers: { 'X-CSRF-Token': token }, credentials: 'same-origin' });
|
||
if (stopRes.ok) { window.toastManager && window.toastManager.info('Timer stopped'); }
|
||
window.location.href = "{{ url_for('main.dashboard') }}";
|
||
} else {
|
||
window.location.href = "{{ url_for('timer.manual_entry') }}";
|
||
}
|
||
} catch (_) { window.location.href = "{{ url_for('timer.manual_entry') }}"; }
|
||
}
|
||
document.addEventListener('keydown', (e) => {
|
||
if (isTyping(e)) return;
|
||
if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 't' || e.key === 'T')) {
|
||
e.preventDefault();
|
||
toggleTimer();
|
||
}
|
||
});
|
||
})();
|
||
</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 to let Tailwind width utilities react
|
||
const icon = document.getElementById('sidebarCollapseIcon');
|
||
if (isCollapsed){
|
||
appShell.classList.add('sidebar-collapsed');
|
||
sidebar.classList.add('w-16');
|
||
sidebar.classList.remove('w-64');
|
||
// Adjust main content margin for collapsed sidebar
|
||
main.classList.remove('lg:ml-64');
|
||
main.classList.add('lg:ml-16');
|
||
icon && icon.classList.remove('fa-angles-left');
|
||
icon && icon.classList.add('fa-angles-right');
|
||
} else {
|
||
appShell.classList.remove('sidebar-collapsed');
|
||
sidebar.classList.remove('w-16');
|
||
sidebar.classList.add('w-64');
|
||
// Adjust main content margin for expanded sidebar
|
||
main.classList.remove('lg:ml-16');
|
||
main.classList.add('lg: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 simply shows/hides sidebar via utility classes
|
||
mobileBtn && mobileBtn.addEventListener('click', function(){
|
||
// On small screens we can temporarily show sidebar as overlay
|
||
const showing = sidebar.classList.contains('hidden');
|
||
sidebar.classList.toggle('hidden', !showing);
|
||
// Sidebar is already fixed, just need to adjust z-index for overlay
|
||
if (showing) {
|
||
sidebar.style.zIndex = '50';
|
||
} else {
|
||
sidebar.style.zIndex = '10';
|
||
}
|
||
});
|
||
|
||
// 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 dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg 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', hideFlyout, true);
|
||
}
|
||
}
|
||
function showFlyout(triggerBtn, listId){
|
||
ensureFlyout();
|
||
const list = document.getElementById(listId);
|
||
if (!list) return;
|
||
flyout.innerHTML = '';
|
||
const ul = document.createElement('ul');
|
||
ul.className = 'min-w-[12rem] space-y-1';
|
||
list.querySelectorAll('a').forEach((a) => {
|
||
const li = document.createElement('li');
|
||
const link = a.cloneNode(true);
|
||
link.classList.add('block','px-3','py-2','rounded','hover:bg-background-light','dark:hover:bg-background-dark');
|
||
li.appendChild(link);
|
||
ul.appendChild(li);
|
||
});
|
||
flyout.appendChild(ul);
|
||
const rect = triggerBtn.getBoundingClientRect();
|
||
const sbRect = sidebar.getBoundingClientRect();
|
||
const left = sbRect.right + 8 + window.scrollX;
|
||
const top = rect.top + window.scrollY;
|
||
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>
|
||
<!-- Command Palette Modal (restored, Tailwind-styled, no Bootstrap required) -->
|
||
<!-- Keyboard Shortcuts Help Modal -->
|
||
{% include 'components/keyboard_shortcuts_help.html' %}
|
||
|
||
<div id="commandPaletteModal" class="fixed inset-0 z-50 hidden">
|
||
<div class="absolute inset-0 bg-black/50" onclick="document.getElementById('commandPaletteModal').classList.add('hidden')"></div>
|
||
<div class="relative max-w-2xl mx-auto mt-24 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-xl">
|
||
<div class="p-3 sm:p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between">
|
||
<div id="commandPaletteHelp" class="text-xs text-text-muted-light dark:text-text-muted-dark">
|
||
Press <kbd class="px-1 py-0.5 bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark rounded text-xs">Esc</kbd> to close, <kbd class="px-1 py-0.5 bg-background-light dark:bg-background-dark border border-border-light dark:border-border-dark rounded text-xs">Shift+?</kbd> for all shortcuts
|
||
</div>
|
||
<button id="commandPaletteClose" class="px-2 py-1 text-sm hover:bg-background-light dark:hover:bg-background-dark rounded">Close</button>
|
||
</div>
|
||
<div class="p-3 sm:p-4">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<i class="fas fa-terminal text-text-muted-light dark:text-text-muted-dark"></i>
|
||
<input id="commandPaletteInput" type="text" class="w-full bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg py-2 px-3 focus:outline-none focus:ring-2 focus:ring-primary" placeholder="{{ _('Type a command or search...') }}" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||
</div>
|
||
<div id="commandPaletteList" class="flex flex-col max-h-96 overflow-y-auto divide-y divide-border-light dark:divide-border-dark"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
// Command Palette close handlers
|
||
(function() {
|
||
const modal = document.getElementById('commandPaletteModal');
|
||
const closeBtn = document.getElementById('commandPaletteClose');
|
||
const input = document.getElementById('commandPaletteInput');
|
||
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', () => modal.classList.add('hidden'));
|
||
}
|
||
|
||
// Close on Escape key
|
||
if (input) {
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
modal.classList.add('hidden');
|
||
}
|
||
});
|
||
}
|
||
})();
|
||
</script>
|
||
<script>
|
||
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||
|
||
// Change the icons inside the button based on previous settings
|
||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||
themeToggleLightIcon.classList.remove('hidden');
|
||
} else {
|
||
themeToggleDarkIcon.classList.remove('hidden');
|
||
}
|
||
|
||
var themeToggleBtn = document.getElementById('theme-toggle');
|
||
|
||
themeToggleBtn.addEventListener('click', function() {
|
||
// toggle icons inside button
|
||
themeToggleDarkIcon.classList.toggle('hidden');
|
||
themeToggleLightIcon.classList.toggle('hidden');
|
||
|
||
var newTheme;
|
||
// if set via local storage previously
|
||
if (localStorage.getItem('color-theme')) {
|
||
if (localStorage.getItem('color-theme') === 'light') {
|
||
document.documentElement.classList.add('dark');
|
||
localStorage.setItem('color-theme', 'dark');
|
||
newTheme = 'dark';
|
||
} else {
|
||
document.documentElement.classList.remove('dark');
|
||
localStorage.setItem('color-theme', 'light');
|
||
newTheme = 'light';
|
||
}
|
||
// if NOT set via local storage previously
|
||
} else {
|
||
if (document.documentElement.classList.contains('dark')) {
|
||
document.documentElement.classList.remove('dark');
|
||
localStorage.setItem('color-theme', 'light');
|
||
newTheme = 'light';
|
||
} else {
|
||
document.documentElement.classList.add('dark');
|
||
localStorage.setItem('color-theme', 'dark');
|
||
newTheme = 'dark';
|
||
}
|
||
}
|
||
|
||
// Save to database if user is logged in
|
||
{% 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(response => {
|
||
if (!response.ok) {
|
||
return response.json().then(data => {
|
||
throw new Error(data.error || 'Failed to save theme preference');
|
||
});
|
||
}
|
||
return response.json();
|
||
})
|
||
.then(data => {
|
||
if (data.success) {
|
||
console.log('Theme preference saved:', data.theme);
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to save theme preference:', err);
|
||
// Don't show error toast for theme changes - it's not critical
|
||
// The theme change already worked locally, just didn't persist
|
||
});
|
||
{% endif %}
|
||
});
|
||
|
||
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
|
||
const nestedDropdowns = {
|
||
'pdfDropdown': 'adminSettingsDropdown',
|
||
'adminUserMgmtDropdown': 'adminDropdown',
|
||
'adminSettingsDropdown': 'adminDropdown',
|
||
'adminSecurityDropdown': 'adminDropdown',
|
||
'adminIntegrationsDropdown': 'adminDropdown',
|
||
'adminDataDropdown': 'adminDropdown',
|
||
'adminMaintenanceDropdown': 'adminDropdown',
|
||
'reportsDropdown': 'financeDropdown' // Reports is nested under Finance
|
||
};
|
||
|
||
// 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'];
|
||
allSidebarDropdowns.forEach(dropdownId => {
|
||
// Don't close the parent if this is a nested dropdown
|
||
if (dropdownId === parentDropdown) {
|
||
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 opening a submenu of adminDropdown, ensure adminDropdown is also open
|
||
if (parentDropdown === 'adminDropdown') {
|
||
const adminDropdown = document.getElementById('adminDropdown');
|
||
if (adminDropdown) {
|
||
adminDropdown.classList.remove('hidden');
|
||
}
|
||
}
|
||
// If opening reportsDropdown, ensure financeDropdown is also open
|
||
if (parentDropdown === 'financeDropdown') {
|
||
const financeDropdown = document.getElementById('financeDropdown');
|
||
if (financeDropdown) {
|
||
financeDropdown.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)';
|
||
}
|
||
}
|
||
}
|
||
</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>
|
||
<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>
|
||
|
||
<!-- Advanced Features -->
|
||
<script src="{{ url_for('static', filename='keyboard-shortcuts-advanced.js') }}?v=2.2"></script>
|
||
<script src="{{ url_for('static', filename='keyboard-shortcuts-enhanced.js') }}?v=2.2"></script>
|
||
<script src="{{ url_for('static', filename='quick-actions.js') }}"></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>
|
||
|
||
<!-- PWA Registration -->
|
||
<script>
|
||
if ('serviceWorker' in navigator) {
|
||
window.addEventListener('load', () => {
|
||
navigator.serviceWorker.register('/static/service-worker.js')
|
||
.then(registration => {
|
||
console.log('ServiceWorker registered:', registration);
|
||
|
||
// Check for updates
|
||
registration.addEventListener('updatefound', () => {
|
||
const newWorker = registration.installing;
|
||
newWorker.addEventListener('statechange', () => {
|
||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||
// New version available
|
||
if (window.toastManager) {
|
||
const toast = window.toastManager.info('New version available!', 0);
|
||
const btn = document.createElement('button');
|
||
btn.textContent = 'Reload';
|
||
btn.className = 'ml-2 px-3 py-1 bg-primary text-white rounded hover:bg-primary/90';
|
||
btn.onclick = () => window.location.reload();
|
||
toast.appendChild(btn);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
})
|
||
.catch(err => console.log('ServiceWorker registration failed:', err));
|
||
});
|
||
}
|
||
|
||
// Install prompt
|
||
let deferredPrompt;
|
||
|
||
// Check if user has previously dismissed the install prompt or app is already installed
|
||
const installPromptDismissed = localStorage.getItem('pwa-install-dismissed');
|
||
const isAppInstalled = window.matchMedia('(display-mode: standalone)').matches ||
|
||
window.navigator.standalone === true ||
|
||
document.referrer.includes('android-app://');
|
||
|
||
// Only register the event listener if prompt hasn't been dismissed and app isn't installed
|
||
if (installPromptDismissed !== 'true' && !isAppInstalled) {
|
||
window.addEventListener('beforeinstallprompt', (e) => {
|
||
e.preventDefault();
|
||
|
||
// Double-check dismissal status when event fires
|
||
const stillDismissed = localStorage.getItem('pwa-install-dismissed');
|
||
if (stillDismissed === 'true') {
|
||
return; // Don't show the prompt if it was dismissed
|
||
}
|
||
|
||
deferredPrompt = e;
|
||
|
||
// Show install button in UI
|
||
if (window.toastManager) {
|
||
// Create a non-dismissible toast so we can add custom buttons
|
||
const toastId = window.toastManager.show({
|
||
message: 'Install TimeTracker as an app!',
|
||
type: 'info',
|
||
duration: 0,
|
||
dismissible: false
|
||
});
|
||
// Get the toast element after it's added to DOM
|
||
requestAnimationFrame(() => {
|
||
const toastElement = document.querySelector(`[data-toast-id="${toastId}"]`);
|
||
if (toastElement) {
|
||
const btn = document.createElement('button');
|
||
btn.textContent = 'Install';
|
||
btn.className = 'ml-2 px-3 py-1 bg-primary text-white rounded hover:bg-primary/90';
|
||
btn.onclick = async () => {
|
||
if (!deferredPrompt) return;
|
||
deferredPrompt.prompt();
|
||
const { outcome } = await deferredPrompt.userChoice;
|
||
if (outcome === 'accepted') {
|
||
window.toastManager.success('App installed successfully!');
|
||
}
|
||
// Always mark as dismissed after user interaction
|
||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||
deferredPrompt = null;
|
||
window.toastManager.dismiss(toastId);
|
||
};
|
||
|
||
// Add a dismiss button
|
||
const dismissBtn = document.createElement('button');
|
||
dismissBtn.textContent = '×';
|
||
dismissBtn.className = 'ml-2 px-2 py-1 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded';
|
||
dismissBtn.title = 'Dismiss permanently';
|
||
dismissBtn.onclick = () => {
|
||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||
deferredPrompt = null;
|
||
window.toastManager.dismiss(toastId);
|
||
};
|
||
|
||
// Insert buttons in the toast content area
|
||
const content = toastElement.querySelector('.toast-content');
|
||
if (content) {
|
||
content.appendChild(btn);
|
||
content.appendChild(dismissBtn);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Also listen for app installation to mark as dismissed
|
||
window.addEventListener('appinstalled', () => {
|
||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||
deferredPrompt = null;
|
||
});
|
||
}
|
||
</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
|
||
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 7 days)
|
||
try {
|
||
localStorage.setItem('supportBannerDismissed', Date.now().toString());
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
|
||
function shouldShowSupportBanner() {
|
||
try {
|
||
const dismissed = localStorage.getItem('supportBannerDismissed');
|
||
if (!dismissed) return true;
|
||
const dismissedTime = parseInt(dismissed);
|
||
const sevenDays = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
|
||
return (Date.now() - dismissedTime) > sevenDays;
|
||
} catch(e) {
|
||
return true; // Show by default if localStorage fails
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
if (shouldShowSupportBanner()) {
|
||
// 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');
|
||
}, 2000); // Show after 2 seconds
|
||
} else {
|
||
// Keep it hidden and collapsed - no space reserved
|
||
banner.classList.add('max-h-0', 'overflow-hidden');
|
||
}
|
||
})();
|
||
</script>
|
||
|
||
<script src="{{ url_for('static', filename='data-tables-enhanced.js') }}"></script>
|
||
{% block scripts_extra %}{% endblock %}
|
||
</body>
|
||
</html>
|
||
|