mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 21:30:12 -05:00
91127bf188
Smart notifications (opt-in under user settings): NotificationService builds candidates from the user's local day and active timers; GET /api/notifications and POST /api/notifications/dismiss; migration 150 adds user columns and user_smart_notification_dismissals. /api/summary/today uses the same local-day totals. Client polls from smart-notifications.js; toastManager.show gains onDismiss for server dismiss sync. Config and env.example document SMART_NOTIFY_* variables. Value dashboard: StatsService with Redis-backed caching, GET /api/stats/value-dashboard, dashboard template and dashboard-enhancements polling alongside existing widgets. API v1 token search now uses apply_project_scope and apply_client_scope on queries; scope_filter adds apply_project_scope; tests extended for the new helper.
625 lines
42 KiB
HTML
625 lines
42 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ _('Settings') }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
|
<div class="mb-8">
|
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ _('Settings') }}</h1>
|
|
<p class="text-gray-600 dark:text-gray-400 mt-2">{{ _('Manage your account settings and preferences') }}</p>
|
|
</div>
|
|
|
|
<details class="bg-card-light dark:bg-card-dark rounded-lg shadow-md group mb-6" open>
|
|
<summary class="flex items-center justify-between p-6 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
<i class="fas fa-heart mr-2 text-amber-600 dark:text-amber-400" aria-hidden="true"></i>{{ _('Support & Community') }}
|
|
</h2>
|
|
<i class="fas fa-chevron-down text-gray-400 transition-transform group-open:rotate-180 md:hidden"></i>
|
|
</summary>
|
|
<div class="px-6 pb-6 space-y-4 text-sm text-text-light dark:text-text-dark">
|
|
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('TimeTracker is free and open source. Funding comes from optional donations and supporter licenses — never from locking features.') }}</p>
|
|
{% if is_license_activated %}
|
|
<p>{{ _('This instance already has a supporter license. Thank you — you can still donate or share the app anytime.') }}</p>
|
|
{% else %}
|
|
<p>{{ _('If the app saves you time, you can donate or buy a supporter license (€25). A license shows a Supporter badge; it does not change what you can use.') }}</p>
|
|
{% endif %}
|
|
<div class="flex flex-wrap gap-2">
|
|
<button type="button" class="btn btn-primary js-open-support-modal">{{ _('Support TimeTracker') }}</button>
|
|
<a href="{{ url_for('user.license') }}" class="btn btn-secondary inline-flex items-center gap-2"><i class="fas fa-key" aria-hidden="true"></i>{{ _('License & supporter key') }}</a>
|
|
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">{{ _('Checkout on timetracker.drytrix.com') }} <i class="fas fa-external-link-alt text-xs" aria-hidden="true"></i></a>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<form method="POST" class="space-y-8">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
|
|
<!-- Profile Information -->
|
|
<details class="bg-card-light dark:bg-card-dark rounded-lg shadow-md group" open>
|
|
<summary class="flex items-center justify-between p-6 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
<i class="fas fa-user mr-2"></i>{{ _('Profile Information') }}
|
|
</h2>
|
|
<i class="fas fa-chevron-down text-gray-400 transition-transform group-open:rotate-180 md:hidden"></i>
|
|
</summary>
|
|
<div class="px-6 pb-6">
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="form-label">
|
|
{{ _('Username') }}
|
|
</label>
|
|
<input type="text" value="{{ user.username }}" disabled
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-100 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 cursor-not-allowed">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Username cannot be changed') }}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="full_name" class="form-label">
|
|
{{ _('Full Name') }}
|
|
</label>
|
|
<input type="text" id="full_name" name="full_name" value="{{ user.full_name or '' }}"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
|
</div>
|
|
|
|
<div class="md:col-span-2">
|
|
<label for="email" class="form-label">
|
|
{{ _('Email Address') }}
|
|
</label>
|
|
<input type="email" id="email" name="email" value="{{ user.email or '' }}"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
placeholder="your.email@example.com">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Required for email notifications') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<!-- Notification Preferences -->
|
|
<details class="bg-card-light dark:bg-card-dark rounded-lg shadow-md group" open>
|
|
<summary class="flex items-center justify-between p-6 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
<i class="fas fa-bell mr-2"></i>{{ _('Notification Preferences') }}
|
|
</h2>
|
|
<i class="fas fa-chevron-down text-gray-400 transition-transform group-open:rotate-180 md:hidden"></i>
|
|
</summary>
|
|
<div class="px-6 pb-6">
|
|
|
|
<div class="space-y-2">
|
|
<label for="email_notifications" class="flex items-center min-h-[44px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="email_notifications" name="email_notifications"
|
|
{% if user.email_notifications %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 text-sm text-gray-700 dark:text-gray-300">
|
|
<span class="font-medium">{{ _('Enable Email Notifications') }}</span>
|
|
<span class="block text-xs text-gray-500 dark:text-gray-400">{{ _('Master switch for all email notifications') }}</span>
|
|
</span>
|
|
</label>
|
|
|
|
<div class="ml-4 sm:ml-8 space-y-1 border-l-2 border-gray-200 dark:border-gray-700 pl-4">
|
|
<label for="notification_overdue_invoices" class="flex items-center min-h-[44px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="notification_overdue_invoices" name="notification_overdue_invoices"
|
|
{% if user.notification_overdue_invoices %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
|
|
{{ _('Overdue Invoice Notifications') }}
|
|
</span>
|
|
</label>
|
|
|
|
<label for="notification_task_assigned" class="flex items-center min-h-[44px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="notification_task_assigned" name="notification_task_assigned"
|
|
{% if user.notification_task_assigned %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
|
|
{{ _('Task Assignment Notifications') }}
|
|
</span>
|
|
</label>
|
|
|
|
<label for="notification_task_comments" class="flex items-center min-h-[44px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="notification_task_comments" name="notification_task_comments"
|
|
{% if user.notification_task_comments %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
|
|
{{ _('Comment & Mention Notifications') }}
|
|
</span>
|
|
</label>
|
|
|
|
<label for="notification_weekly_summary" class="flex items-center min-h-[44px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="notification_weekly_summary" name="notification_weekly_summary"
|
|
{% if user.notification_weekly_summary %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
|
|
{{ _('Weekly Time Summary Email') }}
|
|
</span>
|
|
</label>
|
|
|
|
<label for="notification_remind_to_log" class="flex items-center min-h-[44px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="notification_remind_to_log" name="notification_remind_to_log"
|
|
{% if user.notification_remind_to_log %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
|
|
{{ _('Remind me to log time at end of day') }}
|
|
</span>
|
|
</label>
|
|
<div class="ml-8 mt-1 {% if not user.notification_remind_to_log %}opacity-60{% endif %}" id="reminder_time_wrap">
|
|
<label for="reminder_to_log_time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('Reminder time (your timezone)') }}</label>
|
|
<input type="time" id="reminder_to_log_time" name="reminder_to_log_time" value="{{ user.reminder_to_log_time or '17:00' }}"
|
|
class="w-32 px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white text-sm">
|
|
</div>
|
|
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 mb-2">{{ _('In-app reminders (toasts)') }}</p>
|
|
<label for="smart_notifications_enabled" class="flex items-center min-h-[44px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="smart_notifications_enabled" name="smart_notifications_enabled"
|
|
{% if user.smart_notifications_enabled %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
|
|
{{ _('Enable smart notifications on this device') }}
|
|
</span>
|
|
</label>
|
|
<div class="ml-2 mt-2 space-y-2 {% if not user.smart_notifications_enabled %}opacity-60{% endif %}" id="smart_notify_sub_wrap">
|
|
<label for="smart_notify_no_tracking" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="smart_notify_no_tracking" name="smart_notify_no_tracking"
|
|
{% if user.smart_notify_no_tracking %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('Nudge when no time logged today (uses hour from app settings or override below)') }}</span>
|
|
</label>
|
|
<label for="smart_notify_long_timer" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="smart_notify_long_timer" name="smart_notify_long_timer"
|
|
{% if user.smart_notify_long_timer %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('Alert when a timer runs longer than the configured threshold') }}</span>
|
|
</label>
|
|
<label for="smart_notify_daily_summary" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="smart_notify_daily_summary" name="smart_notify_daily_summary"
|
|
{% if user.smart_notify_daily_summary %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('End-of-day summary (logged hours today)') }}</span>
|
|
</label>
|
|
<label for="smart_notify_browser" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="smart_notify_browser" name="smart_notify_browser"
|
|
{% if user.smart_notify_browser %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
|
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('Also use browser notifications when permission is granted') }}</span>
|
|
</label>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-2">
|
|
<div>
|
|
<label for="smart_notify_no_tracking_after" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('No-tracking nudge hour (optional override, HH:MM)') }}</label>
|
|
<input type="time" id="smart_notify_no_tracking_after" name="smart_notify_no_tracking_after"
|
|
value="{{ user.smart_notify_no_tracking_after or '' }}"
|
|
class="w-full max-w-[12rem] px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white text-sm">
|
|
</div>
|
|
<div>
|
|
<label for="smart_notify_summary_at" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('Summary hour (optional override, HH:MM)') }}</label>
|
|
<input type="time" id="smart_notify_summary_at" name="smart_notify_summary_at"
|
|
value="{{ user.smart_notify_summary_at or '' }}"
|
|
class="w-full max-w-[12rem] px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white text-sm">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<!-- Display Preferences -->
|
|
<details class="bg-card-light dark:bg-card-dark rounded-lg shadow-md group" open>
|
|
<summary class="flex items-center justify-between p-6 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
<i class="fas fa-palette mr-2"></i>{{ _('Display Preferences') }}
|
|
</h2>
|
|
<i class="fas fa-chevron-down text-gray-400 transition-transform group-open:rotate-180 md:hidden"></i>
|
|
</summary>
|
|
<div class="px-6 pb-6">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label for="theme_preference" class="form-label">
|
|
{{ _('Theme') }}
|
|
</label>
|
|
<select id="theme_preference" name="theme_preference"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
|
<option value="">{{ _('System Default') }}</option>
|
|
<option value="light" {% if user.theme_preference == 'light' %}selected{% endif %}>☀️ {{ _('Light') }}</option>
|
|
<option value="dark" {% if user.theme_preference == 'dark' %}selected{% endif %}>🌙 {{ _('Dark') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="preferred_language" class="form-label">
|
|
{{ _('Language') }}
|
|
</label>
|
|
<select id="preferred_language" name="preferred_language"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
|
<option value="">{{ _('System Default') }}</option>
|
|
{% for code, name in languages.items() %}
|
|
<option value="{{ code }}" {% if current_language_code == code %}selected{% endif %}>{{ name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<!-- Time Rounding Preferences -->
|
|
<details class="bg-card-light dark:bg-card-dark rounded-lg shadow-md group" open>
|
|
<summary class="flex items-center justify-between p-6 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
<i class="fas fa-clock mr-2"></i>{{ _('Time Rounding Preferences') }}
|
|
</h2>
|
|
<i class="fas fa-chevron-down text-gray-400 transition-transform group-open:rotate-180 md:hidden"></i>
|
|
</summary>
|
|
<div class="px-6 pb-6">
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
{{ _('Configure how your time entries are rounded. This affects how durations are calculated when you stop timers.') }}
|
|
</p>
|
|
|
|
<div class="space-y-4">
|
|
<label for="time_rounding_enabled" class="flex items-center min-h-[44px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="time_rounding_enabled" name="time_rounding_enabled"
|
|
{% if user.time_rounding_enabled %}checked{% endif %}
|
|
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0"
|
|
onchange="toggleRoundingOptions()">
|
|
<span class="ml-3 text-sm text-gray-700 dark:text-gray-300">
|
|
<span class="font-medium">{{ _('Enable Time Rounding') }}</span>
|
|
<span class="block text-xs text-gray-500 dark:text-gray-400">{{ _('Round time entries to configured intervals') }}</span>
|
|
</span>
|
|
</label>
|
|
|
|
<div id="rounding-options" class="ml-8 space-y-4 border-l-2 border-gray-200 dark:border-gray-700 pl-4">
|
|
<div>
|
|
<label for="time_rounding_minutes" class="form-label">
|
|
{{ _('Rounding Interval') }}
|
|
</label>
|
|
<select id="time_rounding_minutes" name="time_rounding_minutes"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
|
{% for minutes, label in rounding_intervals %}
|
|
<option value="{{ minutes }}" {% if user.time_rounding_minutes == minutes %}selected{% endif %}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Time entries will be rounded to this interval') }}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="time_rounding_method" class="form-label">
|
|
{{ _('Rounding Method') }}
|
|
</label>
|
|
<select id="time_rounding_method" name="time_rounding_method"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
onchange="updateRoundingMethodDescription()">
|
|
{% for method, label, description in rounding_methods %}
|
|
<option value="{{ method }}" data-description="{{ description }}" {% if user.time_rounding_method == method %}selected{% endif %}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<p id="rounding-method-description" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></p>
|
|
</div>
|
|
|
|
<!-- Example visualization -->
|
|
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-md">
|
|
<p class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
|
<i class="fas fa-info-circle mr-1"></i>{{ _('Example') }}
|
|
</p>
|
|
<div id="rounding-example" class="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
|
<!-- Will be populated by JavaScript -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<!-- Overtime Settings -->
|
|
<details class="bg-card-light dark:bg-card-dark rounded-lg shadow-md group" open>
|
|
<summary class="flex items-center justify-between p-6 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
<i class="fas fa-business-time mr-2"></i>{{ _('Overtime Settings') }}
|
|
</h2>
|
|
<i class="fas fa-chevron-down text-gray-400 transition-transform group-open:rotate-180 md:hidden"></i>
|
|
</summary>
|
|
<div class="px-6 pb-6">
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
{{ _('Choose whether overtime is calculated per day or per week, and set your standard hours accordingly.') }}
|
|
</p>
|
|
|
|
<div class="mb-4">
|
|
<label class="form-label block mb-2">{{ _('Calculate overtime by') }}</label>
|
|
<div class="flex flex-wrap gap-4">
|
|
<label class="inline-flex items-center cursor-pointer">
|
|
<input type="radio" name="overtime_calculation_mode" value="daily"
|
|
{% if getattr(user, 'overtime_calculation_mode', 'daily') == 'daily' %}checked{% endif %}
|
|
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 dark:bg-gray-700 dark:border-gray-600">
|
|
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{ _('Daily hours') }}</span>
|
|
</label>
|
|
<label class="inline-flex items-center cursor-pointer">
|
|
<input type="radio" name="overtime_calculation_mode" value="weekly"
|
|
{% if getattr(user, 'overtime_calculation_mode', 'daily') == 'weekly' %}checked{% endif %}
|
|
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 dark:bg-gray-700 dark:border-gray-600">
|
|
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{ _('Weekly hours') }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="overtime-daily-fields" style="display: {% if getattr(user, 'overtime_calculation_mode', 'daily') != 'weekly' %}grid{% else %}none{% endif %};">
|
|
<div>
|
|
<label for="standard_hours_per_day" class="form-label">
|
|
{{ _('Standard Hours Per Day') }}
|
|
</label>
|
|
<div class="relative">
|
|
<input type="number" id="standard_hours_per_day" name="standard_hours_per_day"
|
|
value="{{ user.standard_hours_per_day }}"
|
|
min="0.5" max="24" step="0.5"
|
|
class="w-full px-3 py-2 pr-16 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
|
|
<span class="absolute right-3 top-2.5 text-sm text-gray-500 dark:text-gray-400 pointer-events-none">{{ _('hours') }}</span>
|
|
</div>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Typically 8 hours for a full-time job') }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4" id="overtime-weekly-fields" style="display: {% if getattr(user, 'overtime_calculation_mode', 'daily') == 'weekly' %}grid{% else %}none{% endif %};">
|
|
<div>
|
|
<label for="standard_hours_per_week" class="form-label">
|
|
{{ _('Standard Hours Per Week') }}
|
|
</label>
|
|
<div class="relative">
|
|
<input type="number" id="standard_hours_per_week" name="standard_hours_per_week"
|
|
value="{{ user.standard_hours_per_week or '' }}"
|
|
min="1" max="168" step="0.5" placeholder="{{ (user.standard_hours_per_day or 8) * 5 }}"
|
|
class="w-full px-3 py-2 pr-16 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
|
|
<span class="absolute right-3 top-2.5 text-sm text-gray-500 dark:text-gray-400 pointer-events-none">{{ _('hours') }}</span>
|
|
</div>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('e.g. 20 for a part-time week, 40 for full-time') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center bg-blue-50 dark:bg-blue-900/20 p-4 rounded-md mt-4" id="overtime-how-daily" style="display: {% if getattr(user, 'overtime_calculation_mode', 'daily') != 'weekly' %}block{% else %}none{% endif %};">
|
|
<div>
|
|
<p class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1">
|
|
<i class="fas fa-info-circle mr-1"></i>{{ _('How it works') }}
|
|
</p>
|
|
<p class="text-xs text-blue-800 dark:text-blue-200">
|
|
{{ _('If you work more than your standard hours in a day, the extra time will be tracked as overtime in reports and analytics.') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center bg-blue-50 dark:bg-blue-900/20 p-4 rounded-md mt-4" id="overtime-how-weekly" style="display: {% if getattr(user, 'overtime_calculation_mode', 'daily') == 'weekly' %}block{% else %}none{% endif %};">
|
|
<div>
|
|
<p class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1">
|
|
<i class="fas fa-info-circle mr-1"></i>{{ _('How it works') }}
|
|
</p>
|
|
<p class="text-xs text-blue-800 dark:text-blue-200">
|
|
{{ _('Overtime is calculated per week: any hours beyond your standard hours per week count as overtime. Suited for contracts based on weekly hours.') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4">
|
|
<label for="overtime_include_weekends" class="flex items-center min-h-[44px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<input type="checkbox" id="overtime_include_weekends" name="overtime_include_weekends"
|
|
{% if getattr(user, 'overtime_include_weekends', true) %}checked{% endif %}
|
|
class="h-5 w-5 rounded border-gray-300 text-primary focus:ring-primary dark:bg-gray-700 dark:border-gray-600 flex-shrink-0">
|
|
<span class="ml-3 text-sm text-gray-700 dark:text-gray-300">
|
|
{{ _('Count weekend hours in overtime') }}
|
|
<span class="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{{ _('When unchecked, any hours worked on Saturday or Sunday are always counted as overtime.') }}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<!-- Regional Settings -->
|
|
<details class="bg-card-light dark:bg-card-dark rounded-lg shadow-md group" open>
|
|
<summary class="flex items-center justify-between p-6 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
<i class="fas fa-globe mr-2"></i>{{ _('Regional Settings') }}
|
|
</h2>
|
|
<i class="fas fa-chevron-down text-gray-400 transition-transform group-open:rotate-180 md:hidden"></i>
|
|
</summary>
|
|
<div class="px-6 pb-6">
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label for="timezone" class="form-label">
|
|
{{ _('Timezone') }}
|
|
</label>
|
|
<select id="timezone" name="timezone"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
|
<option value="">{{ _('System Default') }}</option>
|
|
{% for tz in timezones %}
|
|
<option value="{{ tz }}" {% if user.timezone == tz %}selected{% endif %}>{{ tz }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="date_format" class="form-label">
|
|
{{ _('Date Format') }}
|
|
</label>
|
|
<select id="date_format" name="date_format"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
|
<option value="" {% if not user.date_format %}selected{% endif %}>{{ _('Use system default') }}</option>
|
|
<option value="YYYY-MM-DD" {% if user.date_format == 'YYYY-MM-DD' %}selected{% endif %}>YYYY-MM-DD (2025-01-22)</option>
|
|
<option value="MM/DD/YYYY" {% if user.date_format == 'MM/DD/YYYY' %}selected{% endif %}>MM/DD/YYYY (01/22/2025)</option>
|
|
<option value="DD/MM/YYYY" {% if user.date_format == 'DD/MM/YYYY' %}selected{% endif %}>DD/MM/YYYY (22/01/2025)</option>
|
|
<option value="DD.MM.YYYY" {% if user.date_format == 'DD.MM.YYYY' %}selected{% endif %}>DD.MM.YYYY (22.01.2025)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="time_format" class="form-label">
|
|
{{ _('Time Format') }}
|
|
</label>
|
|
<select id="time_format" name="time_format"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
|
<option value="" {% if not user.time_format %}selected{% endif %}>{{ _('Use system default') }}</option>
|
|
<option value="24h" {% if user.time_format == '24h' %}selected{% endif %}>24-hour (14:30)</option>
|
|
<option value="12h" {% if user.time_format == '12h' %}selected{% endif %}>12-hour (2:30 PM)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="week_start_day" class="form-label">
|
|
{{ _('Week Starts On') }}
|
|
</label>
|
|
<select id="week_start_day" name="week_start_day"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
|
<option value="0" {% if user.week_start_day == 0 %}selected{% endif %}>{{ _('Sunday') }}</option>
|
|
<option value="1" {% if user.week_start_day == 1 %}selected{% endif %}>{{ _('Monday') }}</option>
|
|
<option value="6" {% if user.week_start_day == 6 %}selected{% endif %}>{{ _('Saturday') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="calendar_default_view" class="form-label">
|
|
{{ _('Calendar default view') }}
|
|
</label>
|
|
<select id="calendar_default_view" name="calendar_default_view"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
|
<option value="" {% if not user.calendar_default_view %}selected{% endif %}>{{ _('Remember last view') }}</option>
|
|
<option value="day" {% if user.calendar_default_view == 'day' %}selected{% endif %}>{{ _('Day') }}</option>
|
|
<option value="week" {% if user.calendar_default_view == 'week' %}selected{% endif %}>{{ _('Week') }}</option>
|
|
<option value="month" {% if user.calendar_default_view == 'month' %}selected{% endif %}>{{ _('Month') }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
|
|
{{ _('Support visibility (hiding donate/support UI) is configured system-wide by administrators in Admin → Settings.') }}
|
|
{{ _('Administrators can purchase a key to hide these prompts:') }}
|
|
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">{{ _('Support & Purchase Key') }}</a>.
|
|
</p>
|
|
|
|
<!-- Save Button - Sticky on mobile -->
|
|
<div class="sticky bottom-0 z-10 bg-background-light/95 dark:bg-background-dark/95 backdrop-blur-sm border-t border-border-light dark:border-border-dark -mx-4 px-4 py-3 sm:static sm:bg-transparent sm:dark:bg-transparent sm:backdrop-blur-none sm:border-0 sm:mx-0 sm:px-0 sm:py-0">
|
|
<div class="flex flex-col-reverse sm:flex-row justify-end gap-3 sm:space-x-4 sm:gap-0">
|
|
<a href="{{ url_for('main.dashboard') }}" class="px-6 py-2.5 border border-gray-300 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition text-center">
|
|
{{ _('Cancel') }}
|
|
</a>
|
|
<button type="submit" class="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition">
|
|
<i class="fas fa-save mr-2"></i>{{ _('Save Settings') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<script>
|
|
// Live theme preview
|
|
document.getElementById('theme_preference').addEventListener('change', function() {
|
|
const theme = this.value;
|
|
if (theme === 'dark') {
|
|
document.documentElement.classList.add('dark');
|
|
} else if (theme === 'light') {
|
|
document.documentElement.classList.remove('dark');
|
|
} else {
|
|
// System default
|
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Show warning if email notifications are enabled but no email is provided
|
|
document.getElementById('email_notifications').addEventListener('change', function() {
|
|
const emailField = document.getElementById('email');
|
|
if (this.checked && !emailField.value) {
|
|
emailField.classList.add('border-yellow-500');
|
|
emailField.focus();
|
|
} else {
|
|
emailField.classList.remove('border-yellow-500');
|
|
}
|
|
});
|
|
|
|
// Toggle rounding options visibility
|
|
function toggleRoundingOptions() {
|
|
const enabled = document.getElementById('time_rounding_enabled').checked;
|
|
const options = document.getElementById('rounding-options');
|
|
|
|
if (enabled) {
|
|
options.style.opacity = '1';
|
|
options.querySelectorAll('select').forEach(select => select.disabled = false);
|
|
} else {
|
|
options.style.opacity = '0.5';
|
|
options.querySelectorAll('select').forEach(select => select.disabled = true);
|
|
}
|
|
updateRoundingExample();
|
|
}
|
|
|
|
// Update rounding method description
|
|
function updateRoundingMethodDescription() {
|
|
const select = document.getElementById('time_rounding_method');
|
|
const description = select.options[select.selectedIndex].getAttribute('data-description');
|
|
document.getElementById('rounding-method-description').textContent = description;
|
|
updateRoundingExample();
|
|
}
|
|
|
|
// Update rounding example visualization
|
|
function updateRoundingExample() {
|
|
const enabled = document.getElementById('time_rounding_enabled').checked;
|
|
const minutes = parseInt(document.getElementById('time_rounding_minutes').value);
|
|
const method = document.getElementById('time_rounding_method').value;
|
|
const exampleDiv = document.getElementById('rounding-example');
|
|
|
|
if (!enabled) {
|
|
exampleDiv.innerHTML = '<p>{{ _("Time rounding is disabled. All times will be recorded exactly as tracked.") }}</p>';
|
|
return;
|
|
}
|
|
|
|
if (minutes === 1) {
|
|
exampleDiv.innerHTML = '<p>{{ _("No rounding - times will be recorded exactly as tracked.") }}</p>';
|
|
return;
|
|
}
|
|
|
|
// Calculate examples
|
|
const testDuration = 62; // 62 minutes = 1h 2min
|
|
let rounded;
|
|
|
|
if (method === 'up') {
|
|
rounded = Math.ceil(testDuration / minutes) * minutes;
|
|
} else if (method === 'down') {
|
|
rounded = Math.floor(testDuration / minutes) * minutes;
|
|
} else {
|
|
rounded = Math.round(testDuration / minutes) * minutes;
|
|
}
|
|
|
|
const formatTime = (mins) => {
|
|
const hours = Math.floor(mins / 60);
|
|
const remainingMins = mins % 60;
|
|
if (hours > 0) {
|
|
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
|
|
}
|
|
return `${remainingMins}m`;
|
|
};
|
|
|
|
exampleDiv.innerHTML = `
|
|
<p><strong>{{ _("Actual time:") }}</strong> ${formatTime(testDuration)} → <strong>{{ _("Rounded:") }}</strong> ${formatTime(rounded)}</p>
|
|
<p class="text-xs opacity-75">{{ _("With ") }}${minutes}{{ _(" minute intervals") }}</p>
|
|
`;
|
|
}
|
|
|
|
// Toggle overtime daily vs weekly fields
|
|
function toggleOvertimeMode() {
|
|
const mode = document.querySelector('input[name="overtime_calculation_mode"]:checked');
|
|
const isWeekly = mode && mode.value === 'weekly';
|
|
document.getElementById('overtime-daily-fields').style.display = isWeekly ? 'none' : 'grid';
|
|
document.getElementById('overtime-weekly-fields').style.display = isWeekly ? 'grid' : 'none';
|
|
document.getElementById('overtime-how-daily').style.display = isWeekly ? 'none' : 'block';
|
|
document.getElementById('overtime-how-weekly').style.display = isWeekly ? 'block' : 'none';
|
|
}
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
toggleRoundingOptions();
|
|
updateRoundingMethodDescription();
|
|
toggleOvertimeMode();
|
|
|
|
document.getElementById('time_rounding_enabled').addEventListener('change', updateRoundingExample);
|
|
document.getElementById('time_rounding_minutes').addEventListener('change', updateRoundingExample);
|
|
document.querySelectorAll('input[name="overtime_calculation_mode"]').forEach(function(radio) {
|
|
radio.addEventListener('change', toggleOvertimeMode);
|
|
});
|
|
});
|
|
</script>
|
|
{% endblock %}
|
|
|