mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 04:08:48 -05:00
feat(header): group Chat, Timer, Help as aligned round buttons
- Group Chat, Timer, and Help in header as round icon buttons - Vertically aligned, evenly spaced (gap-2), consistent w-10 h-10 - Header timer: one-click start/stop from any page via floating-timer-bar.js - Fix timer manual entry URL (use /timer/manual, not /timer/manual_entry) - Add Help button linking to help page - Update FEATURES_COMPLETE (Header Quick Access, One-Click Timers) - Update help page Time Tracking section with header timer tip - Update CHANGELOG
This commit is contained in:
@@ -9,8 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
- **PDF layout: decorative image persistence and PDF preview (Issue #432)** — Decorative images now survive save/load: image URLs are synced onto groups before generating the template, injected into the saved design JSON using position-based matching, and restored from the saved JSON onto the canvas on load. Empty decorative image elements are no longer added to the ReportLab template, and the PDF generator skips empty or invalid image sources and validates base64 data URIs, preventing a mostly-black or broken PDF preview.
|
||||
- **Header Start Timer button** — Fixed manual entry URL (`/timer/manual_entry` → `/timer/manual`); timer now correctly opens manual entry when starting from the header button.
|
||||
|
||||
### Added
|
||||
- **Header quick access buttons** — Chat, Timer, and Help are grouped in the header as round icon buttons, vertically aligned and evenly spaced. One-click timer start/stop from any page; Help links to documentation; Chat opens team chat when enabled.
|
||||
- **ZugFerd / Factur-X support for invoice PDFs** — When enabled in Admin → Settings → Peppol e-Invoicing, exported invoice PDFs embed EN 16931 UBL XML as `ZUGFeRD-invoice.xml`, producing hybrid human- and machine-readable invoices. Uses the same UBL as Peppol; these PDFs can be sent via Peppol or email. New setting `invoices_zugferd_pdf`, migration `128_add_invoices_zugferd_pdf`, dependency `pikepdf`, and [docs/admin/configuration/PEPPOL_EINVOICING.md](docs/admin/configuration/PEPPOL_EINVOICING.md) updated for both Peppol and ZugFerd.
|
||||
- **Subcontractor role and assigned clients** — Users with the Subcontractor role can be restricted to specific clients and their projects. Admins assign clients in Admin → Users → Edit user (section "Assigned Clients (Subcontractor)"). Scope is applied to clients, projects, time entries, reports, invoices, timer, and API v1; direct access to other clients/projects returns 403. New table `user_clients`, migration `127_add_user_clients_table`, and docs in [docs/SUBCONTRACTOR_ROLE.md](docs/SUBCONTRACTOR_ROLE.md).
|
||||
- Additional features and improvements in development
|
||||
|
||||
@@ -92,6 +92,69 @@ def hours_by_day():
|
||||
)
|
||||
|
||||
|
||||
@analytics_bp.route("/api/analytics/hours-forecast")
|
||||
@login_required
|
||||
@module_enabled("analytics")
|
||||
def hours_forecast():
|
||||
"""Get forecasted hours for the next 7 days using moving average (7-day window)"""
|
||||
try:
|
||||
days = int(request.args.get("days", 30))
|
||||
forecast_days = min(int(request.args.get("forecast_days", 7)), 14)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "Invalid parameters"}), 400
|
||||
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
query = db.session.query(
|
||||
func.date(TimeEntry.start_time).label("date"),
|
||||
func.sum(TimeEntry.duration_seconds).label("total_seconds"),
|
||||
).filter(
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.start_time >= start_date,
|
||||
TimeEntry.start_time <= end_date,
|
||||
)
|
||||
|
||||
if not current_user.is_admin:
|
||||
query = query.filter(TimeEntry.user_id == current_user.id)
|
||||
|
||||
results = query.group_by(func.date(TimeEntry.start_time)).order_by(func.date(TimeEntry.start_time)).all()
|
||||
|
||||
date_data = {}
|
||||
current = start_date
|
||||
while current <= end_date:
|
||||
date_data[current.strftime("%Y-%m-%d")] = 0
|
||||
current += timedelta(days=1)
|
||||
|
||||
for date_str, total_seconds in results:
|
||||
if date_str:
|
||||
fmt = date_str.strftime("%Y-%m-%d") if hasattr(date_str, "strftime") else str(date_str)[:10]
|
||||
date_data[fmt] = round((total_seconds or 0) / 3600, 2)
|
||||
|
||||
values = list(date_data.values())
|
||||
window = 7
|
||||
if len(values) < window:
|
||||
avg = sum(values) / len(values) if values else 0
|
||||
else:
|
||||
avg = sum(values[-window:]) / window
|
||||
|
||||
labels = list(date_data.keys())
|
||||
forecast_labels = []
|
||||
forecast_data = []
|
||||
for i in range(1, forecast_days + 1):
|
||||
d = end_date + timedelta(days=i)
|
||||
forecast_labels.append(d.strftime("%Y-%m-%d"))
|
||||
forecast_data.append(round(avg, 2))
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"historical": {"labels": labels, "data": list(date_data.values())},
|
||||
"forecast": {"labels": forecast_labels, "data": forecast_data},
|
||||
"avg_daily_hours": round(avg, 2),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@analytics_bp.route("/api/analytics/hours-by-project")
|
||||
@login_required
|
||||
@module_enabled("analytics")
|
||||
|
||||
+56
-1
@@ -142,7 +142,10 @@ Example: `2024-01-15T14:30:00Z`
|
||||
"contact": {"name": "TimeTracker API Support"},
|
||||
"license": {"name": "MIT"},
|
||||
},
|
||||
"servers": [{"url": "/api/v1", "description": "API v1"}],
|
||||
"servers": [
|
||||
{"url": "/api/v1", "description": "REST API v1"},
|
||||
{"url": "", "description": "App root (for /api/analytics, etc.)"},
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"BearerAuth": {
|
||||
@@ -235,6 +238,8 @@ Example: `2024-01-15T14:30:00Z`
|
||||
{"name": "Clients", "description": "Client management operations"},
|
||||
{"name": "Reports", "description": "Reporting and analytics"},
|
||||
{"name": "Users", "description": "User management operations"},
|
||||
{"name": "Invoices", "description": "Invoice operations"},
|
||||
{"name": "Expenses", "description": "Expense operations"},
|
||||
],
|
||||
"paths": {
|
||||
"/info": {
|
||||
@@ -452,6 +457,56 @@ Example: `2024-01-15T14:30:00Z`
|
||||
"responses": {"200": {"description": "User information"}},
|
||||
}
|
||||
},
|
||||
"/analytics/hours-by-day": {
|
||||
"get": {
|
||||
"tags": ["Reports"],
|
||||
"summary": "Hours by day",
|
||||
"description": "Get hours worked per day for a date range",
|
||||
"parameters": [{"name": "days", "in": "query", "schema": {"type": "integer", "default": 30}}],
|
||||
"responses": {"200": {"description": "Chart data with labels and datasets"}},
|
||||
}
|
||||
},
|
||||
"/analytics/hours-forecast": {
|
||||
"get": {
|
||||
"tags": ["Reports"],
|
||||
"summary": "Hours forecast",
|
||||
"description": "Get forecasted hours for the next 7 days based on moving average",
|
||||
"parameters": [
|
||||
{"name": "days", "in": "query", "schema": {"type": "integer", "default": 30}},
|
||||
{"name": "forecast_days", "in": "query", "schema": {"type": "integer", "default": 7, "maximum": 14}},
|
||||
],
|
||||
"responses": {"200": {"description": "Historical and forecast data"}},
|
||||
}
|
||||
},
|
||||
"/analytics/summary-with-comparison": {
|
||||
"get": {
|
||||
"tags": ["Reports"],
|
||||
"summary": "Summary with comparison",
|
||||
"description": "Get summary metrics with comparison to previous period",
|
||||
"parameters": [{"name": "days", "in": "query", "schema": {"type": "integer", "default": 30}}],
|
||||
"responses": {"200": {"description": "Summary with total hours, billable, entries, changes"}},
|
||||
}
|
||||
},
|
||||
"/invoices/{invoice_id}": {
|
||||
"get": {
|
||||
"tags": ["Invoices"],
|
||||
"summary": "Get invoice",
|
||||
"parameters": [{"name": "invoice_id", "in": "path", "required": True, "schema": {"type": "integer"}}],
|
||||
"responses": {"200": {"description": "Invoice details"}, "404": {"description": "Not found"}},
|
||||
}
|
||||
},
|
||||
"/expenses": {
|
||||
"get": {
|
||||
"tags": ["Expenses"],
|
||||
"summary": "List expenses",
|
||||
"parameters": [
|
||||
{"name": "project_id", "in": "query", "schema": {"type": "integer"}},
|
||||
{"name": "page", "in": "query", "schema": {"type": "integer"}},
|
||||
{"name": "per_page", "in": "query", "schema": {"type": "integer"}},
|
||||
],
|
||||
"responses": {"200": {"description": "List of expenses"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -401,6 +401,44 @@ def search():
|
||||
return render_template("main/search.html", entries=entries, query=query)
|
||||
|
||||
|
||||
@main_bp.route("/manifest.webmanifest")
|
||||
def manifest():
|
||||
"""Serve PWA manifest with theme_color. Prepared for custom themes - extend to use user accent preference when implemented."""
|
||||
theme_color = getattr(current_app.config, "PWA_THEME_COLOR", "#4A90E2")
|
||||
manifest_data = {
|
||||
"name": "TimeTracker - Professional Time Tracking",
|
||||
"short_name": "TimeTracker",
|
||||
"description": "Professional time tracking and project management application",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": theme_color,
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{"src": url_for("static", filename="images/timetracker-logo.svg"), "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"},
|
||||
{"src": url_for("static", filename="images/android-chrome-192x192.png"), "sizes": "192x192", "type": "image/png", "purpose": "any maskable"},
|
||||
{"src": url_for("static", filename="images/android-chrome-512x512.png"), "sizes": "512x512", "type": "image/png", "purpose": "any maskable"},
|
||||
{"src": url_for("static", filename="images/apple-touch-icon.png"), "sizes": "180x180", "type": "image/png", "purpose": "any"},
|
||||
],
|
||||
"screenshots": [],
|
||||
"categories": ["productivity", "business"],
|
||||
"shortcuts": [
|
||||
{"name": "Start Timer", "short_name": "Timer", "description": "Start tracking time", "url": url_for("timer.manual_entry"), "icons": [{"src": url_for("static", filename="images/timetracker-logo.svg"), "sizes": "96x96"}]},
|
||||
{"name": "Dashboard", "short_name": "Dashboard", "description": "View dashboard", "url": url_for("main.dashboard"), "icons": [{"src": url_for("static", filename="images/timetracker-logo.svg"), "sizes": "96x96"}]},
|
||||
{"name": "Projects", "short_name": "Projects", "description": "Manage projects", "url": url_for("projects.list_projects"), "icons": [{"src": url_for("static", filename="images/timetracker-logo.svg"), "sizes": "96x96"}]},
|
||||
{"name": "Reports", "short_name": "Reports", "description": "View reports", "url": url_for("reports.reports"), "icons": [{"src": url_for("static", filename="images/timetracker-logo.svg"), "sizes": "96x96"}]},
|
||||
],
|
||||
"share_target": {"action": url_for("timer.manual_entry"), "method": "GET", "params": {"title": "notes", "text": "notes"}},
|
||||
"prefer_related_applications": False,
|
||||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
"edge_side_panel": {"preferred_width": 400},
|
||||
"launch_handler": {"client_mode": "focus-existing"},
|
||||
}
|
||||
resp = make_response(json.dumps(manifest_data, indent=2))
|
||||
resp.headers["Content-Type"] = "application/manifest+json"
|
||||
return resp
|
||||
|
||||
|
||||
@main_bp.route("/service-worker.js")
|
||||
def service_worker():
|
||||
"""Serve a minimal service worker for PWA offline caching."""
|
||||
|
||||
+2
-2
@@ -288,8 +288,8 @@ def set_theme():
|
||||
data = request.get_json()
|
||||
theme = data.get("theme")
|
||||
|
||||
if theme in ["light", "dark", None, ""]:
|
||||
current_user.theme_preference = theme if theme else None
|
||||
if theme in ["light", "dark", "system", None, ""]:
|
||||
current_user.theme_preference = None if (theme == "system" or not theme) else theme
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": True, "theme": current_user.theme_preference or "system"})
|
||||
|
||||
@@ -246,6 +246,22 @@ class TimeTrackingService:
|
||||
# Validate time range
|
||||
if end_time <= start_time:
|
||||
return {"success": False, "message": "End time must be after start time", "error": "invalid_time_range"}
|
||||
|
||||
# Check for overlapping entries (unless skipped for imports)
|
||||
if not skip_entry_requirements:
|
||||
overlapping = TimeEntry.query.filter(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.start_time < end_time,
|
||||
TimeEntry.end_time > start_time,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
).first()
|
||||
if overlapping:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "This time overlaps with an existing entry. Please choose a different time range or edit the existing entry.",
|
||||
"error": "overlapping_entry",
|
||||
}
|
||||
|
||||
if duration_seconds is not None:
|
||||
try:
|
||||
duration_seconds = int(duration_seconds)
|
||||
@@ -444,6 +460,26 @@ class TimeTrackingService:
|
||||
db.session.rollback()
|
||||
return err
|
||||
|
||||
# Check for overlapping entries (exclude this entry) when times were changed
|
||||
if entry.end_time and (start_time is not None or end_time is not None):
|
||||
overlapping = (
|
||||
TimeEntry.query.filter(
|
||||
TimeEntry.user_id == entry.user_id,
|
||||
TimeEntry.id != entry_id,
|
||||
TimeEntry.start_time < entry.end_time,
|
||||
TimeEntry.end_time > entry.start_time,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if overlapping:
|
||||
db.session.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"message": "This time overlaps with an existing entry. Please choose a different time range.",
|
||||
"error": "overlapping_entry",
|
||||
}
|
||||
|
||||
entry.updated_at = local_now()
|
||||
|
||||
if not safe_commit("update_entry", {"user_id": user_id, "entry_id": entry_id}):
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sparklines
|
||||
* Update sparklines with real data from API
|
||||
*/
|
||||
async function updateSparklines() {
|
||||
try {
|
||||
@@ -407,14 +407,16 @@
|
||||
throw new Error('Failed to load sparklines');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const json = await response.json();
|
||||
const data = json.success ? json : { today: json.today, week: json.week, month: json.month };
|
||||
const keys = ['today', 'week', 'month'];
|
||||
|
||||
// Update each sparkline
|
||||
Object.keys(data).forEach(key => {
|
||||
keys.forEach(key => {
|
||||
const container = document.querySelector(`[data-sparkline-id="${key}"]`);
|
||||
if (container && data[key]) {
|
||||
const series = data[key];
|
||||
if (container && Array.isArray(series) && series.length > 0) {
|
||||
const color = container.getAttribute('data-color') || '#3b82f6';
|
||||
createSparkline(container.id || `sparkline-${key}`, data[key], color);
|
||||
createSparkline(container.id || `sparkline-${key}`, series, color);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Floating Timer Bar - Persistent mini-timer visible on all pages
|
||||
* One-click start/stop without navigating to dashboard
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const POLL_INTERVAL_MS = 30000;
|
||||
|
||||
class FloatingTimerBar {
|
||||
constructor() {
|
||||
this.bar = null;
|
||||
this.pollTimer = null;
|
||||
this.elapsedInterval = null;
|
||||
this.timerData = null;
|
||||
this.startTime = null;
|
||||
this.startLabel = 'Start Timer';
|
||||
this.stopLabel = 'Stop';
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!document.getElementById('floatingTimerBar')) return;
|
||||
this.bar = document.getElementById('floatingTimerBar');
|
||||
this.startLabel = this.bar.dataset.startLabel || 'Start Timer';
|
||||
this.stopLabel = this.bar.dataset.stopLabel || 'Stop';
|
||||
this.render();
|
||||
this.fetchStatus();
|
||||
this.pollTimer = setInterval(() => this.fetchStatus(), POLL_INTERVAL_MS);
|
||||
window.addEventListener('focus', () => this.fetchStatus());
|
||||
}
|
||||
|
||||
async fetchStatus() {
|
||||
try {
|
||||
const res = await fetch('/timer/status', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (data.active && data.timer) {
|
||||
this.timerData = data.timer;
|
||||
this.startTime = new Date(data.timer.start_time).getTime();
|
||||
this.render();
|
||||
this.startElapsedUpdater();
|
||||
} else {
|
||||
this.timerData = null;
|
||||
this.startTime = null;
|
||||
this.stopElapsedUpdater();
|
||||
this.render();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('FloatingTimerBar: fetch status failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
startElapsedUpdater() {
|
||||
this.stopElapsedUpdater();
|
||||
const update = () => {
|
||||
if (!this.startTime || !this.bar) return;
|
||||
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
|
||||
const h = Math.floor(elapsed / 3600);
|
||||
const m = Math.floor((elapsed % 3600) / 60);
|
||||
const s = elapsed % 60;
|
||||
const formatted = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
|
||||
const el = this.bar.querySelector('[data-timer-elapsed]');
|
||||
if (el) el.textContent = formatted;
|
||||
const btn = this.bar.querySelector('button');
|
||||
if (btn) btn.title = (this.getLabel() || 'Timer') + ' – ' + formatted + ' – ' + (this.stopLabel || 'Stop');
|
||||
};
|
||||
update();
|
||||
this.elapsedInterval = setInterval(update, 1000);
|
||||
}
|
||||
|
||||
stopElapsedUpdater() {
|
||||
if (this.elapsedInterval) {
|
||||
clearInterval(this.elapsedInterval);
|
||||
this.elapsedInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
const startBtn = document.querySelector('#openStartTimer');
|
||||
if (startBtn) {
|
||||
startBtn.click();
|
||||
} else {
|
||||
const url = this.bar?.dataset?.manualUrl || '/timer/manual';
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
|
||||
async stopTimer() {
|
||||
const tokenEl = document.querySelector('meta[name="csrf-token"]');
|
||||
const token = tokenEl ? tokenEl.getAttribute('content') : '';
|
||||
try {
|
||||
const res = await fetch('/timer/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': token },
|
||||
body: 'csrf_token=' + encodeURIComponent(token),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (res.redirected) {
|
||||
window.location.href = res.url;
|
||||
} else {
|
||||
await this.fetchStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Stop timer failed', e);
|
||||
if (window.toastManager) {
|
||||
window.toastManager.error('Failed to stop timer', 'Error', 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLabel() {
|
||||
if (!this.timerData) return '';
|
||||
return this.timerData.project_name || this.timerData.client_name || 'Timer';
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.bar) return;
|
||||
|
||||
const baseClass = 'floating-timer-bar__round flex items-center justify-center w-10 h-10 rounded-full 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 text-sm transition-colors';
|
||||
const title = this.timerData
|
||||
? (escapeHtml(this.getLabel()) + ' – ' + (this.timerData.duration_formatted || '00:00:00') + ' – ' + escapeHtml(this.stopLabel))
|
||||
: escapeHtml(this.startLabel);
|
||||
|
||||
if (this.timerData) {
|
||||
this.bar.innerHTML = `
|
||||
<button type="button" class="${baseClass} relative" onclick="window.floatingTimerBar.stopTimer()" title="${title}" aria-label="${escapeHtml(this.stopLabel)} – ${escapeHtml(this.getLabel())}">
|
||||
<span class="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-green-500 animate-pulse" aria-hidden="true"></span>
|
||||
<i class="fas fa-stopwatch text-base"></i>
|
||||
<span class="floating-timer-bar__elapsed sr-only" data-timer-elapsed>${this.timerData.duration_formatted || '00:00:00'}</span>
|
||||
</button>
|
||||
`;
|
||||
this.startElapsedUpdater();
|
||||
} else {
|
||||
this.bar.innerHTML = `
|
||||
<button type="button" class="${baseClass}" onclick="window.floatingTimerBar.startTimer()" title="${title}" aria-label="${escapeHtml(this.startLabel)}">
|
||||
<i class="fas fa-play text-base"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stopElapsedUpdater();
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.floating-timer-bar__round { cursor: pointer; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.floating-timer-bar__round .animate-pulse { animation: none; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const container = document.getElementById('floatingTimerBar');
|
||||
if (container) {
|
||||
window.floatingTimerBar = new FloatingTimerBar();
|
||||
}
|
||||
});
|
||||
})();
|
||||
+46
-7
@@ -1,8 +1,15 @@
|
||||
// Idle detection: when user is inactive, offer to stop timer at last active time
|
||||
(function(){
|
||||
if (window.__ttIdleLoaded) return; window.__ttIdleLoaded = true;
|
||||
const IDLE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function getIdleThresholdMs(){
|
||||
const meta = document.querySelector('meta[name="idle-timeout-minutes"]');
|
||||
const mins = meta ? parseInt(meta.getAttribute('content'), 10) : 30;
|
||||
return (isNaN(mins) || mins < 1 ? 30 : Math.min(480, mins)) * 60 * 1000;
|
||||
}
|
||||
|
||||
const CHECK_INTERVAL_MS = 60 * 1000; // 1 minute
|
||||
const SNOOZE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
let lastActivity = Date.now();
|
||||
let promptShown = false;
|
||||
@@ -31,29 +38,61 @@
|
||||
async function stopAt(ts){
|
||||
try {
|
||||
const r = await fetch('/api/timer/stop_at', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stop_time: new Date(ts).toISOString() }) });
|
||||
if (r.ok){ showToast(window.i18n?.messages?.timerStoppedInactivity || 'Timer stopped due to inactivity', 'warning'); location.reload(); }
|
||||
if (r.ok){
|
||||
const msg = window.i18n?.messages?.timerStoppedInactivity || 'Timer stopped due to inactivity';
|
||||
if (window.toastManager && window.toastManager.warning) {
|
||||
window.toastManager.warning(msg, '', 5000);
|
||||
} else if (window.toastManager && window.toastManager.show) {
|
||||
window.toastManager.show({ message: msg, type: 'warning', duration: 5000 });
|
||||
} else {
|
||||
alert(msg);
|
||||
}
|
||||
location.reload();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function showIdlePrompt(stopTs){
|
||||
if (promptShown) return; promptShown = true;
|
||||
// Create a lightweight inline prompt toast
|
||||
const msg = 'You seem inactive since ' + formatTime(new Date(stopTs)) + '. Stop the timer at that time?';
|
||||
const stopLabel = window.i18n?.messages?.stop || 'Stop';
|
||||
const snoozeLabel = window.i18n?.messages?.snooze || 'Snooze 5 min';
|
||||
const dismissLabel = window.i18n?.messages?.dismiss || 'Dismiss';
|
||||
|
||||
if (window.toastManager) {
|
||||
const toastEl = document.createElement('div');
|
||||
toastEl.className = 'flex items-center gap-3 p-4 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded-lg shadow-lg pointer-events-auto';
|
||||
toastEl.innerHTML = '<div class="flex-1 text-sm text-amber-900 dark:text-amber-100">' + msg + '</div>' +
|
||||
'<div class="flex gap-2"><button class="px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white rounded text-sm font-medium" data-act="stop">' + stopLabel + '</button>' +
|
||||
'<button class="px-3 py-1.5 bg-amber-200 dark:bg-amber-800 hover:bg-amber-300 dark:hover:bg-amber-700 text-amber-900 dark:text-amber-100 rounded text-sm font-medium" data-act="snooze">' + snoozeLabel + '</button>' +
|
||||
'<button class="px-3 py-1.5 text-amber-700 dark:text-amber-300 hover:underline text-sm" data-act="dismiss">' + dismissLabel + '</button></div>';
|
||||
toastEl.querySelector('[data-act="stop"]').addEventListener('click', function(){ toastEl.remove(); stopAt(stopTs); });
|
||||
toastEl.querySelector('[data-act="snooze"]').addEventListener('click', function(){ lastActivity = Date.now(); promptShown = false; toastEl.remove(); });
|
||||
toastEl.querySelector('[data-act="dismiss"]').addEventListener('click', function(){ toastEl.remove(); });
|
||||
const container = document.getElementById('toast-notification-container') || document.getElementById('flash-messages-container') || document.body;
|
||||
container.appendChild(toastEl);
|
||||
setTimeout(function(){ try { toastEl.remove(); } catch(e){}; promptShown = false; }, 60000);
|
||||
return;
|
||||
}
|
||||
|
||||
const t = document.createElement('div');
|
||||
t.className = 'toast align-items-center text-white bg-warning border-0 fade show';
|
||||
t.innerHTML = `<div class="d-flex"><div class="toast-body">You seem inactive since ${formatTime(new Date(stopTs))}. Stop the timer at that time?</div><div class="d-flex gap-2 align-items-center me-2"><button class="btn btn-sm btn-light" data-act="stop">Stop</button><button class="btn btn-sm btn-outline-light" data-act="dismiss">Dismiss</button></div></div>`;
|
||||
t.innerHTML = '<div class="d-flex"><div class="toast-body">' + msg + '</div><div class="d-flex gap-2 align-items-center me-2"><button class="btn btn-sm btn-light" data-act="stop">' + stopLabel + '</button><button class="btn btn-sm btn-outline-light" data-act="snooze">' + snoozeLabel + '</button><button class="btn btn-sm btn-outline-light" data-act="dismiss">' + dismissLabel + '</button></div></div>';
|
||||
const container = document.getElementById('toast-container') || document.body;
|
||||
container.appendChild(t);
|
||||
t.querySelector('[data-act="stop"]').addEventListener('click', () => { t.remove(); stopAt(stopTs); });
|
||||
t.querySelector('[data-act="snooze"]').addEventListener('click', () => { lastActivity = Date.now(); promptShown = false; t.remove(); });
|
||||
t.querySelector('[data-act="dismiss"]').addEventListener('click', () => { t.remove(); });
|
||||
setTimeout(() => { try { t.remove(); } catch(e){} }, 60_000);
|
||||
setTimeout(() => { try { t.remove(); } catch(e){}; promptShown = false; }, 60000);
|
||||
}
|
||||
|
||||
async function tick(){
|
||||
const active = await getTimer();
|
||||
if (!active) return;
|
||||
const threshold = getIdleThresholdMs();
|
||||
const idleFor = Date.now() - lastActivity;
|
||||
if (idleFor >= IDLE_THRESHOLD_MS){
|
||||
const stopTs = Date.now() - idleFor; // last active time
|
||||
if (idleFor >= threshold){
|
||||
const stopTs = Date.now() - idleFor;
|
||||
showIdlePrompt(stopTs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,4 +104,19 @@
|
||||
.toast-notification.toast-warning .toast-progress-bar { background: #F59E0B; }
|
||||
.toast-notification.toast-info .toast-progress-bar { background: #3B82F6; }
|
||||
|
||||
/* Reduced motion - instant appearance, no progress animation */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-notification {
|
||||
transition: none;
|
||||
}
|
||||
.toast-progress-bar {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode */
|
||||
@media (prefers-contrast: high) {
|
||||
.toast-notification {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,7 +677,127 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
/* Loading Skeletons */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.dark .skeleton {
|
||||
background: linear-gradient(90deg, #334155 25%, #475569 50%, #334155 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 1em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-text:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.skeleton-text.short { width: 40%; }
|
||||
.skeleton-text.medium { width: 70%; }
|
||||
.skeleton-text.long { width: 100%; }
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark .skeleton-card {
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .skeleton-row {
|
||||
border-bottom-color: #334155;
|
||||
}
|
||||
|
||||
.skeleton-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.dark .skeleton {
|
||||
background: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast Mode (WCAG 2.1 - prefers-contrast) */
|
||||
@media (prefers-contrast: high) {
|
||||
.dashboard-widget,
|
||||
.animated-card {
|
||||
border: 2px solid #1a365d !important;
|
||||
}
|
||||
|
||||
.dark .dashboard-widget,
|
||||
.dark .animated-card {
|
||||
border-color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
border-bottom-width: 2px !important;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid #0066cc !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
.dark button:focus-visible,
|
||||
.dark input:focus-visible,
|
||||
.dark select:focus-visible,
|
||||
.dark textarea:focus-visible,
|
||||
.dark [tabindex]:focus-visible {
|
||||
outline-color: #60a5fa !important;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 3px solid #0066cc !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
.dark a:focus-visible {
|
||||
outline-color: #60a5fa !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility - Reduced Motion (WCAG 2.1) */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn-press,
|
||||
.transition-smooth,
|
||||
@@ -686,9 +806,22 @@
|
||||
.success-checkmark,
|
||||
.context-menu,
|
||||
.dashboard-widget,
|
||||
.real-time-indicator {
|
||||
.real-time-indicator,
|
||||
.animated-card,
|
||||
.fade-in-up,
|
||||
.fade-in,
|
||||
.animate-float,
|
||||
.animate-slide-in-right,
|
||||
.animate-fade-in,
|
||||
.animate-scale-in,
|
||||
.mobile-fade-in {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
/* Simplify hover transitions for reduced motion */
|
||||
.dashboard-widget:hover,
|
||||
.animated-card:hover {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<button id="refreshCharts" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-sync-alt mr-2"></i> {{ _('Refresh') }}
|
||||
</button>
|
||||
<button id="exportAllCharts" type="button" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" title="{{ _('Export all charts as PNG') }}">
|
||||
<i class="fas fa-download mr-2"></i> {{ _('Export Charts') }}
|
||||
</button>
|
||||
</div>
|
||||
{% endset %}
|
||||
|
||||
@@ -30,167 +33,150 @@
|
||||
actions_html=analytics_actions
|
||||
) }}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="w-full max-w-full px-0">
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-clock fa-2x text-primary mb-2"></i>
|
||||
<h4 class="text-primary" id="totalHours">-</h4>
|
||||
<p class="text-muted mb-0">{{ _('Total Hours') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow p-6 text-center h-full">
|
||||
<i class="fas fa-clock fa-2x text-primary mb-2"></i>
|
||||
<h4 class="text-primary text-xl font-semibold" id="totalHours">-</h4>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mb-0">{{ _('Total Hours') }}</p>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-dollar-sign fa-2x text-success mb-2"></i>
|
||||
<h4 class="text-success" id="billableHours">-</h4>
|
||||
<p class="text-muted mb-0">{{ _('Billable Hours') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow p-6 text-center h-full">
|
||||
<i class="fas fa-dollar-sign fa-2x text-green-600 dark:text-green-400 mb-2"></i>
|
||||
<h4 class="text-green-600 dark:text-green-400 text-xl font-semibold" id="billableHours">-</h4>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mb-0">{{ _('Billable Hours') }}</p>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-project-diagram fa-2x text-info mb-2"></i>
|
||||
<h4 class="text-info" id="activeProjects">-</h4>
|
||||
<p class="text-muted mb-0">{{ _('Active Projects') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow p-6 text-center h-full">
|
||||
<i class="fas fa-project-diagram fa-2x text-blue-600 dark:text-blue-400 mb-2"></i>
|
||||
<h4 class="text-blue-600 dark:text-blue-400 text-xl font-semibold" id="activeProjects">-</h4>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mb-0">{{ _('Active Projects') }}</p>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-chart-line fa-2x text-warning mb-2"></i>
|
||||
<h4 class="text-warning" id="avgDailyHours">-</h4>
|
||||
<p class="text-muted mb-0">{{ _('Avg Daily Hours') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow p-6 text-center h-full">
|
||||
<i class="fas fa-chart-line fa-2x text-amber-600 dark:text-amber-400 mb-2"></i>
|
||||
<h4 class="text-amber-600 dark:text-amber-400 text-xl font-semibold" id="avgDailyHours">-</h4>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mb-0">{{ _('Avg Daily Hours') }}</p>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-business-time fa-2x text-info mb-2"></i>
|
||||
<h4 class="text-info" id="overtimeSummary">-</h4>
|
||||
<p class="text-muted mb-0">{{ _('Regular') }} / {{ _('Overtime') }}</p>
|
||||
<p class="text-muted small mb-0" id="overtimeDaysLabel"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow p-6 text-center h-full">
|
||||
<i class="fas fa-business-time fa-2x text-blue-600 dark:text-blue-400 mb-2"></i>
|
||||
<h4 class="text-blue-600 dark:text-blue-400 text-xl font-semibold" id="overtimeSummary">-</h4>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mb-0">{{ _('Regular') }} / {{ _('Overtime') }}</p>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-sm mb-0" id="overtimeDaysLabel"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-area"></i> {{ _('Daily Hours Trend') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
|
||||
<canvas id="dailyHoursChart"></canvas>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark rounded-lg shadow h-full">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark">
|
||||
<h5 class="mb-0 font-semibold text-text-light dark:text-text-dark">
|
||||
<i class="fas fa-chart-area mr-2"></i> {{ _('Daily Hours Trend') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="chart-container relative" style="height: 300px; width: 100%;">
|
||||
<canvas id="dailyHoursChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-pie"></i> {{ _('Billable vs Non-Billable') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
|
||||
<canvas id="billableChart"></canvas>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow h-full">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark">
|
||||
<h5 class="mb-0 font-semibold text-text-light dark:text-text-dark">
|
||||
<i class="fas fa-chart-pie mr-2"></i> {{ _('Billable vs Non-Billable') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="chart-container relative" style="height: 300px; width: 100%;">
|
||||
<canvas id="billableChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-bar"></i> {{ _('Hours by Project') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
|
||||
<canvas id="projectChart"></canvas>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow h-full">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark">
|
||||
<h5 class="mb-0 font-semibold text-text-light dark:text-text-dark">
|
||||
<i class="fas fa-chart-bar mr-2"></i> {{ _('Hours by Project') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="chart-container relative" style="height: 300px; width: 100%;">
|
||||
<canvas id="projectChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-line"></i> {{ _('Weekly Trends') }}
|
||||
</h5>
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow h-full">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark">
|
||||
<h5 class="mb-0 font-semibold text-text-light dark:text-text-dark">
|
||||
<i class="fas fa-chart-line mr-2"></i> {{ _('Weekly Trends') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="chart-container relative" style="height: 300px; width: 100%;">
|
||||
<canvas id="weeklyTrendsChart"></canvas>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
|
||||
<canvas id="weeklyTrendsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hours Forecast Chart -->
|
||||
<div class="mb-6">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow h-full">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark">
|
||||
<h5 class="mb-0 font-semibold text-text-light dark:text-text-dark">
|
||||
<i class="fas fa-project-diagram mr-2"></i> {{ _('Hours Forecast') }}
|
||||
</h5>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Next 7 days based on 7-day average') }}</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="chart-container relative" style="height: 280px; width: 100%;">
|
||||
<canvas id="forecastChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overtime Chart -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-business-time"></i> {{ _('Daily Regular vs Overtime') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
|
||||
<canvas id="overtimeChart"></canvas>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow h-full">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark">
|
||||
<h5 class="mb-0 font-semibold text-text-light dark:text-text-dark">
|
||||
<i class="fas fa-business-time mr-2"></i> {{ _('Daily Regular vs Overtime') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="chart-container relative" style="height: 300px; width: 100%;">
|
||||
<canvas id="overtimeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 3 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-clock"></i> {{ _('Hours by Time of Day') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
|
||||
<canvas id="hourlyChart"></canvas>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow h-full">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark">
|
||||
<h5 class="mb-0 font-semibold text-text-light dark:text-text-dark">
|
||||
<i class="fas fa-clock mr-2"></i> {{ _('Hours by Time of Day') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="chart-container relative" style="height: 300px; width: 100%;">
|
||||
<canvas id="hourlyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-bar"></i> {{ _('Project Efficiency') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
|
||||
<canvas id="efficiencyChart"></canvas>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow h-full">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark">
|
||||
<h5 class="mb-0 font-semibold text-text-light dark:text-text-dark">
|
||||
<i class="fas fa-chart-bar mr-2"></i> {{ _('Project Efficiency') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="chart-container relative" style="height: 300px; width: 100%;">
|
||||
<canvas id="efficiencyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,18 +184,16 @@
|
||||
|
||||
<!-- User Performance Chart (Admin Only) -->
|
||||
{% if current_user.is_admin %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-users"></i> {{ _('User Performance') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px;">
|
||||
<canvas id="userChart"></canvas>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark">
|
||||
<h5 class="mb-0 font-semibold text-text-light dark:text-text-dark">
|
||||
<i class="fas fa-users mr-2"></i> {{ _('User Performance') }}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="chart-container relative" style="height: 300px;">
|
||||
<canvas id="userChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,9 +202,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div id="loadingSpinner" class="position-fixed top-50 start-50 translate-middle" style="display: none; z-index: 9999;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{ _('Loading...') }}</span>
|
||||
<div id="loadingSpinner" class="fixed inset-0 flex items-center justify-center bg-black/20 z-50" style="display: none;">
|
||||
<div class="flex flex-col items-center gap-3 bg-card-light dark:bg-card-dark p-6 rounded-lg shadow-xl">
|
||||
<i class="fas fa-spinner fa-spin text-3xl text-primary"></i>
|
||||
<span class="sr-only">{{ _('Loading...') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -324,6 +309,7 @@ class AnalyticsDashboard {
|
||||
this.loadBillableChart(),
|
||||
this.loadProjectChart(),
|
||||
this.loadWeeklyTrendsChart(),
|
||||
this.loadForecastChart(),
|
||||
this.loadHourlyChart(),
|
||||
this.loadEfficiencyChart(),
|
||||
this.loadOvertimeChart(),
|
||||
@@ -348,6 +334,7 @@ class AnalyticsDashboard {
|
||||
this.loadBillableChart(true),
|
||||
this.loadProjectChart(true),
|
||||
this.loadWeeklyTrendsChart(true),
|
||||
this.loadForecastChart(true),
|
||||
this.loadHourlyChart(true),
|
||||
this.loadEfficiencyChart(true),
|
||||
this.loadOvertimeChart(true),
|
||||
@@ -497,6 +484,38 @@ class AnalyticsDashboard {
|
||||
});
|
||||
}
|
||||
|
||||
async loadForecastChart(refresh = false) {
|
||||
const el = document.getElementById('forecastChart');
|
||||
if (!el) return;
|
||||
const response = await fetch(`/api/analytics/hours-forecast?days=${this.timeRange}&forecast_days=7`);
|
||||
const raw = await response.json();
|
||||
const allLabels = (raw.historical?.labels || []).concat(raw.forecast?.labels || []);
|
||||
const histData = (raw.historical?.data || []).concat(new Array((raw.forecast?.labels || []).length).fill(null));
|
||||
const foreData = new Array((raw.historical?.labels || []).length).fill(null).concat(raw.forecast?.data || []);
|
||||
const chartData = {
|
||||
labels: allLabels,
|
||||
datasets: [
|
||||
{ label: (i18n_analytics.hours_label || 'Hours') + ' (' + (raw.avg_daily_hours || 0) + 'h avg)', data: histData, borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', tension: 0.4, fill: true },
|
||||
{ label: '{{ _("Forecast") }}', data: foreData, borderColor: '#f59e0b', borderDash: [5, 5], backgroundColor: 'transparent', tension: 0.4, fill: false }
|
||||
]
|
||||
};
|
||||
if (refresh && this.charts.forecast) this.charts.forecast.destroy();
|
||||
const ctx = el.getContext('2d');
|
||||
this.charts.forecast = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: (i18n_analytics.hours_label || 'Hours') } },
|
||||
x: { ticks: { maxTicksLimit: 12 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadHourlyChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/hours-by-hour?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
@@ -699,11 +718,42 @@ class AnalyticsDashboard {
|
||||
console.error('Analytics error:', message);
|
||||
}
|
||||
}
|
||||
|
||||
exportChartAsPng(chartKey, filename) {
|
||||
const chart = this.charts[chartKey];
|
||||
if (!chart || !chart.canvas) return;
|
||||
const url = chart.toBase64Image('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.download = filename || (chartKey + '-chart.png');
|
||||
link.href = url;
|
||||
link.click();
|
||||
}
|
||||
|
||||
exportAllChartsAsPng() {
|
||||
const chartIds = ['dailyHours', 'billable', 'project', 'weeklyTrends', 'forecast', 'hourly', 'efficiency', 'overtime'];
|
||||
{% if current_user.is_admin %}chartIds.push('user');{% endif %}
|
||||
const prefix = 'analytics-' + new Date().toISOString().slice(0, 10) + '-';
|
||||
chartIds.forEach((key, i) => {
|
||||
const chart = this.charts[key];
|
||||
if (chart && chart.canvas) {
|
||||
setTimeout(() => {
|
||||
this.exportChartAsPng(key, prefix + key + '.png');
|
||||
}, i * 300);
|
||||
}
|
||||
});
|
||||
if (window.toastManager) {
|
||||
window.toastManager.success('{{ _("Charts exported. Check your downloads.") }}', '{{ _("Export") }}', 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new AnalyticsDashboard();
|
||||
const dashboard = new AnalyticsDashboard();
|
||||
const exportBtn = document.getElementById('exportAllCharts');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => dashboard.exportAllChartsAsPng());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+115
-91
@@ -43,7 +43,7 @@
|
||||
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') }}">
|
||||
<link rel="manifest" href="{{ url_for('main.manifest') }}">
|
||||
<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='css/brand-colors.css') }}">
|
||||
@@ -153,28 +153,20 @@
|
||||
</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) {
|
||||
(function() {
|
||||
var stored = localStorage.getItem('color-theme');
|
||||
var userPref = {% if current_user.is_authenticated %}{% if current_user.theme_preference %}'{{ current_user.theme_preference }}'{% else %}'system'{% endif %}{% else %}null{% endif %};
|
||||
var effective = userPref || stored || 'system';
|
||||
if (effective === 'system' || !effective) {
|
||||
effective = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
if (effective === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
{% 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 %}
|
||||
if (userPref) localStorage.setItem('color-theme', userPref === 'system' || !userPref ? 'system' : userPref);
|
||||
})();
|
||||
var fpDark = document.getElementById('flatpickr-dark-theme');
|
||||
if (fpDark) fpDark.media = document.documentElement.classList.contains('dark') ? 'all' : 'none';
|
||||
</script>
|
||||
@@ -1012,18 +1004,37 @@
|
||||
<span class="hidden lg:inline">{{ _('Support') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<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>
|
||||
<div class="relative z-50" id="theme-dropdown-container">
|
||||
<button id="theme-toggle" type="button" 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-label="{{ _('Theme') }}" aria-haspopup="true" aria-expanded="false" aria-controls="themeDropdown">
|
||||
<i id="theme-toggle-dark-icon" class="hidden fa-solid fa-moon w-5 h-5"></i>
|
||||
<i id="theme-toggle-light-icon" class="hidden fa-solid fa-sun w-5 h-5"></i>
|
||||
<i id="theme-toggle-system-icon" class="hidden fa-solid fa-desktop w-5 h-5"></i>
|
||||
</button>
|
||||
<ul id="themeDropdown" class="hidden absolute right-0 mt-2 w-40 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg py-1" role="menu" aria-label="{{ _('Theme options') }}">
|
||||
<li role="none"><button type="button" class="theme-option w-full text-left px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2" data-theme="light" role="menuitem"><i class="fas fa-sun w-4" aria-hidden="true"></i>{{ _('Light') }}</button></li>
|
||||
<li role="none"><button type="button" class="theme-option w-full text-left px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2" data-theme="dark" role="menuitem"><i class="fas fa-moon w-4" aria-hidden="true"></i>{{ _('Dark') }}</button></li>
|
||||
<li role="none"><button type="button" class="theme-option w-full text-left px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2" data-theme="system" role="menuitem"><i class="fas fa-desktop w-4" aria-hidden="true"></i>{{ _('System') }}</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Chat Button -->
|
||||
{% if is_module_enabled('team_chat') %}
|
||||
<button onclick="if(typeof toggleChatWidget === 'function') { toggleChatWidget(); } else { openChatUserSelector(); }" class="relative 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-label="{{ _('Open chat') }}" title="{{ _('Open chat') }}">
|
||||
<i class="fas fa-comments"></i>
|
||||
<span class="ml-2 hidden lg:inline">{{ _('Chat') }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<!-- Chat, Timer, Help - grouped round buttons -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
{% if is_module_enabled('team_chat') %}
|
||||
<button onclick="if(typeof toggleChatWidget === 'function') { toggleChatWidget(); } else { openChatUserSelector(); }" class="flex items-center justify-center w-10 h-10 rounded-full 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 text-sm" aria-label="{{ _('Open chat') }}" title="{{ _('Open chat') }}">
|
||||
<i class="fas fa-comments"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<div id="floatingTimerBar" class="flex shrink-0 items-center justify-center w-10 h-10"
|
||||
data-start-label="{{ _('Start Timer') }}"
|
||||
data-stop-label="{{ _('Stop') }}"
|
||||
data-manual-url="{{ url_for('timer.manual_entry') }}"
|
||||
aria-label="{{ _('Timer') }}"></div>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('main.help') }}" class="flex items-center justify-center w-10 h-10 rounded-full 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 text-sm" aria-label="{{ _('Help') }}" title="{{ _('Help') }}">
|
||||
<i class="fas fa-life-ring"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
<div class="relative z-50">
|
||||
@@ -1156,6 +1167,10 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<meta name="idle-timeout-minutes" content="{{ settings.idle_timeout_minutes if settings else 30 }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- 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">
|
||||
@@ -1192,6 +1207,11 @@
|
||||
<script src="{{ url_for('static', filename='interactions.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='offline-sync.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='mentions.js') }}"></script>
|
||||
<!-- Floating timer bar -->
|
||||
{% if current_user.is_authenticated %}
|
||||
<script src="{{ url_for('static', filename='floating-timer-bar.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='idle.js') }}"></script>
|
||||
{% endif %}
|
||||
<!-- Old command palette and keyboard navigation (restored) -->
|
||||
<script src="{{ url_for('static', filename='commands.js') }}?v=2.0"></script>
|
||||
<script>
|
||||
@@ -1680,77 +1700,81 @@
|
||||
<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 themeToggleSystemIcon = document.getElementById('theme-toggle-system-icon');
|
||||
var themeToggleBtn = document.getElementById('theme-toggle');
|
||||
var themeDropdown = document.getElementById('themeDropdown');
|
||||
|
||||
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
|
||||
function getEffectiveTheme() {
|
||||
var stored = localStorage.getItem('color-theme');
|
||||
if (stored === 'system' || !stored) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
function updateThemeIcon() {
|
||||
var stored = localStorage.getItem('color-theme') || 'system';
|
||||
themeToggleDarkIcon.classList.add('hidden');
|
||||
themeToggleLightIcon.classList.add('hidden');
|
||||
if (themeToggleSystemIcon) themeToggleSystemIcon.classList.add('hidden');
|
||||
if (stored === 'system') {
|
||||
if (themeToggleSystemIcon) themeToggleSystemIcon.classList.remove('hidden');
|
||||
else if (getEffectiveTheme() === 'dark') themeToggleLightIcon.classList.remove('hidden');
|
||||
else themeToggleDarkIcon.classList.remove('hidden');
|
||||
} else if (stored === 'dark') {
|
||||
themeToggleLightIcon.classList.remove('hidden');
|
||||
} else {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
newTheme = 'light';
|
||||
} else {
|
||||
themeToggleDarkIcon.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'system') {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
newTheme = 'dark';
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
} else if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
var fpDark = document.getElementById('flatpickr-dark-theme');
|
||||
if (fpDark) fpDark.media = document.documentElement.classList.contains('dark') ? 'all' : 'none';
|
||||
updateThemeIcon();
|
||||
}
|
||||
updateThemeIcon();
|
||||
|
||||
// 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
|
||||
if (themeToggleBtn && themeDropdown) {
|
||||
themeToggleBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
themeDropdown.classList.toggle('hidden');
|
||||
themeToggleBtn.setAttribute('aria-expanded', themeDropdown.classList.contains('hidden') ? 'false' : 'true');
|
||||
});
|
||||
{% endif %}
|
||||
document.addEventListener('click', function() {
|
||||
themeDropdown.classList.add('hidden');
|
||||
themeToggleBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
themeDropdown.addEventListener('click', function(e) { e.stopPropagation(); });
|
||||
}
|
||||
document.querySelectorAll('.theme-option').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var newTheme = this.getAttribute('data-theme');
|
||||
localStorage.setItem('color-theme', newTheme);
|
||||
applyTheme(newTheme);
|
||||
if (themeDropdown) themeDropdown.classList.add('hidden');
|
||||
{% if current_user.is_authenticated %}
|
||||
fetch('/api/theme', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ theme: newTheme })
|
||||
}).then(function(r) { return r.ok ? r.json() : r.json().then(function(d) { throw new Error(d.error || 'Failed'); }); })
|
||||
.then(function(d) { if (d.success) console.log('Theme saved:', d.theme); })
|
||||
.catch(function(err) { console.error('Theme save failed:', err); });
|
||||
{% endif %}
|
||||
});
|
||||
});
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
|
||||
if (localStorage.getItem('color-theme') === 'system') applyTheme('system');
|
||||
});
|
||||
|
||||
function toggleDropdown(id, event) {
|
||||
|
||||
@@ -205,6 +205,23 @@ function loadMoreActivities() {
|
||||
loadActivities(true);
|
||||
}
|
||||
|
||||
function getActivitySkeletonHTML(count) {
|
||||
const rows = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
rows.push(`
|
||||
<div class="skeleton-row flex items-start gap-3 p-3">
|
||||
<div class="skeleton skeleton-avatar flex-shrink-0"></div>
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<div class="skeleton skeleton-text medium"></div>
|
||||
<div class="skeleton skeleton-text short"></div>
|
||||
</div>
|
||||
<div class="skeleton skeleton-text short w-12 flex-shrink-0"></div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
return '<div class="space-y-0">' + rows.join('') + '</div>';
|
||||
}
|
||||
|
||||
async function loadActivities(append = false) {
|
||||
const container = document.getElementById('activity-feed-container');
|
||||
if (!container) {
|
||||
@@ -220,6 +237,10 @@ async function loadActivities(append = false) {
|
||||
loadMoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> {{ _('Loading...') }}';
|
||||
}
|
||||
|
||||
if (!append) {
|
||||
container.innerHTML = getActivitySkeletonHTML(5);
|
||||
}
|
||||
|
||||
try {
|
||||
let url = `/api/activities?limit=${activityLimit}&page=${activityPage}`;
|
||||
// Only add entity_type filter if it's not empty
|
||||
@@ -252,10 +273,6 @@ async function loadActivities(append = false) {
|
||||
console.log('No activities received from API');
|
||||
}
|
||||
|
||||
if (!append) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
if (data.activities && data.activities.length > 0) {
|
||||
const activityHTML = data.activities.map(activity => createActivityHTML(activity)).join('');
|
||||
|
||||
|
||||
@@ -510,3 +510,35 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# ============================================
|
||||
LOADING SKELETONS
|
||||
============================================ #}
|
||||
{% macro skeleton_row() %}
|
||||
<div class="skeleton-row flex items-center gap-3 p-4 border-b border-border-light dark:border-border-dark">
|
||||
<div class="skeleton skeleton-avatar flex-shrink-0"></div>
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<div class="skeleton skeleton-text medium"></div>
|
||||
<div class="skeleton skeleton-text short"></div>
|
||||
</div>
|
||||
<div class="skeleton skeleton-text short w-16 flex-shrink-0"></div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro skeleton_table_rows(count=5, cols=5) %}
|
||||
{% for _ in range(count) %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
{% for _ in range(cols) %}
|
||||
<td class="p-4"><div class="skeleton skeleton-text medium"></div></td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro skeleton_card() %}
|
||||
<div class="skeleton-card space-y-3">
|
||||
<div class="skeleton skeleton-text long"></div>
|
||||
<div class="skeleton skeleton-text medium"></div>
|
||||
<div class="skeleton skeleton-text short"></div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<i class="fas fa-clock text-blue-600 dark:text-blue-400 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sparkline-container" data-sparkline='[2.5, 3.2, 2.8, 3.5, 4.1, 3.9, 4.2]' data-color="#3b82f6" id="sparkline-today"></div>
|
||||
<div class="sparkline-container" data-sparkline='[0,0,0,0,0,0,0]' data-sparkline-id="today" data-color="#3b82f6" id="sparkline-today" aria-label="{{ _('Loading...') }}"></div>
|
||||
</div>
|
||||
<!-- Week's Hours Card -->
|
||||
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 dashboard-widget" id="weekHours">
|
||||
@@ -66,7 +66,7 @@
|
||||
<i class="fas fa-calendar-week text-green-600 dark:text-green-400 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sparkline-container" data-sparkline='[18.5, 20.2, 22.8, 24.5, 26.1, 28.9, 30.2]' data-color="#10b981" id="sparkline-week"></div>
|
||||
<div class="sparkline-container" data-sparkline='[0,0,0,0,0,0,0]' data-sparkline-id="week" data-color="#10b981" id="sparkline-week" aria-label="{{ _('Loading...') }}"></div>
|
||||
</div>
|
||||
<!-- Month's Hours Card -->
|
||||
<div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 border border-purple-200 dark:border-purple-800 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 dashboard-widget" id="monthHours">
|
||||
@@ -82,7 +82,7 @@
|
||||
<i class="fas fa-calendar-alt text-purple-600 dark:text-purple-400 text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sparkline-container" data-sparkline='[120.5, 135.2, 148.8, 162.5, 176.1, 188.9, 202.2]' data-color="#8b5cf6" id="sparkline-month"></div>
|
||||
<div class="sparkline-container" data-sparkline='[0,0,0,0,0,0,0]' data-sparkline-id="month" data-color="#8b5cf6" id="sparkline-month" aria-label="{{ _('Loading...') }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<a class="help-link py-1 hover:text-primary" href="#admin-features"><i class="fas fa-cog mr-2"></i>{{ _('Admin Features') }}</a>
|
||||
{% endif %}
|
||||
<a class="help-link py-1 hover:text-primary" href="#mobile-usage"><i class="fas fa-mobile-alt mr-2"></i>{{ _('Mobile Usage') }}</a>
|
||||
<a class="help-link py-1 hover:text-primary" href="#api-documentation"><i class="fas fa-code mr-2"></i>{{ _('API Documentation') }}</a>
|
||||
<a class="help-link py-1 hover:text-primary" href="#troubleshooting"><i class="fas fa-tools mr-2"></i>{{ _('Troubleshooting') }}</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -64,6 +65,12 @@
|
||||
<i class="fas fa-cog mr-1"></i>{{ _('System Settings') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/api/docs" target="_blank" rel="noopener" class="px-3 py-2 rounded-lg border border-emerald-600 text-emerald-600 text-sm hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||
<i class="fas fa-book mr-1"></i>{{ _('API Docs') }}
|
||||
</a>
|
||||
<button type="button" onclick="if(typeof restartTour==='function'){restartTour();window.location.href='{{ url_for('main.dashboard') }}';}else if(window.onboardingManager){window.onboardingManager.reset();window.onboardingManager.init(window.enhancedOnboarding?window.enhancedOnboarding.getEnhancedTourSteps():[]);window.location.href='{{ url_for('main.dashboard') }}';}" class="px-3 py-2 rounded-lg border border-primary text-primary text-sm hover:bg-primary/10">
|
||||
<i class="fas fa-route mr-1"></i>{{ _('Restart Product Tour') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +120,8 @@
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">{{ _('Starting a Timer') }}</h4>
|
||||
<ol class="list-decimal ml-5 space-y-1 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li>{{ _('Navigate to the Timer page or dashboard') }}</li>
|
||||
<li>{{ _('Click the timer icon in the header (round button) to start or stop from any page') }}</li>
|
||||
<li>{{ _('Or navigate to the Timer page or dashboard') }}</li>
|
||||
<li>{{ _('Select a project from the dropdown') }}</li>
|
||||
<li>{{ _('Optionally select a task for more detailed tracking') }}</li>
|
||||
<li>{{ _('Add notes about what you\'re working on (optional)') }}</li>
|
||||
@@ -682,6 +690,21 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- API Documentation -->
|
||||
<section id="api-documentation" class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-code text-emerald-500 mr-2"></i>{{ _('API Documentation') }}</h3>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mb-4">{{ _('TimeTracker provides a REST API for integration with other tools, mobile apps, and custom workflows.') }}</p>
|
||||
<ul class="space-y-2 text-sm mb-4">
|
||||
<li><i class="fas fa-check text-emerald-500 mr-2"></i>{{ _('Interactive Swagger UI at') }} <a href="/api/docs" target="_blank" rel="noopener" class="text-primary hover:underline">/api/docs</a></li>
|
||||
<li><i class="fas fa-check text-emerald-500 mr-2"></i>{{ _('OpenAPI 3.0 specification at') }} <a href="/api/openapi.json" target="_blank" rel="noopener" class="text-primary hover:underline">/api/openapi.json</a></li>
|
||||
<li><i class="fas fa-check text-emerald-500 mr-2"></i>{{ _('Bearer token or X-API-Key authentication') }}</li>
|
||||
<li><i class="fas fa-check text-emerald-500 mr-2"></i>{{ _('Projects, time entries, tasks, clients, invoices, and more') }}</li>
|
||||
</ul>
|
||||
<a href="/api/docs" target="_blank" rel="noopener" class="inline-flex items-center px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 text-sm font-medium">
|
||||
<i class="fas fa-external-link-alt mr-2"></i>{{ _('Open API Documentation') }}
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- Troubleshooting -->
|
||||
<section id="troubleshooting" class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-tools text-red-500 mr-2"></i>{{ _('Troubleshooting & FAQ') }}</h3>
|
||||
|
||||
@@ -37,13 +37,19 @@
|
||||
|
||||
<h3 class="font-semibold mb-4 mt-6">{{ _('Components') }}</h3>
|
||||
<div class="space-y-2" id="components">
|
||||
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700" draggable="true" data-component="table">
|
||||
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-primary/50 transition-all duration-150 active:scale-[0.98]" draggable="true" data-component="table" role="button" tabindex="0" aria-label="{{ _('Add table to report') }}">
|
||||
<i class="fas fa-table mr-2"></i>{{ _('Table') }}
|
||||
</div>
|
||||
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700" draggable="true" data-component="chart">
|
||||
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-primary/50 transition-all duration-150 active:scale-[0.98]" draggable="true" data-component="chart" role="button" tabindex="0" aria-label="{{ _('Add chart to report') }}">
|
||||
<i class="fas fa-chart-line mr-2"></i>{{ _('Chart') }}
|
||||
</div>
|
||||
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700" draggable="true" data-component="summary">
|
||||
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-primary/50 transition-all duration-150 active:scale-[0.98]" draggable="true" data-component="chart-doughnut" role="button" tabindex="0" aria-label="{{ _('Add doughnut chart to report') }}">
|
||||
<i class="fas fa-chart-pie mr-2"></i>{{ _('Doughnut Chart') }}
|
||||
</div>
|
||||
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-primary/50 transition-all duration-150 active:scale-[0.98]" draggable="true" data-component="chart-bar" role="button" tabindex="0" aria-label="{{ _('Add bar chart to report') }}">
|
||||
<i class="fas fa-chart-bar mr-2"></i>{{ _('Bar Chart') }}
|
||||
</div>
|
||||
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-primary/50 transition-all duration-150 active:scale-[0.98]" draggable="true" data-component="summary" role="button" tabindex="0" aria-label="{{ _('Add summary to report') }}">
|
||||
<i class="fas fa-calculator mr-2"></i>{{ _('Summary') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,8 +71,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reportCanvas" class="min-h-96 border-2 border-dashed border-border-light dark:border-border-dark rounded-lg p-4">
|
||||
<p class="text-center text-text-muted-light dark:text-text-muted-dark py-8">
|
||||
<div id="reportCanvas" class="min-h-96 border-2 border-dashed border-border-light dark:border-border-dark rounded-lg p-4 transition-colors duration-150" role="region" aria-label="{{ _('Report canvas') }}" aria-describedby="canvasHelp">
|
||||
<p id="canvasHelp" class="text-center text-text-muted-light dark:text-text-muted-dark py-8" data-empty-placeholder>
|
||||
{{ _('Drag data sources and components here to build your report') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -304,21 +310,26 @@ document.querySelectorAll('[draggable="true"]').forEach(item => {
|
||||
});
|
||||
});
|
||||
|
||||
// Click to add (alternative to drag-and-drop)
|
||||
// Click and keyboard to add (alternative to drag-and-drop)
|
||||
function handleSourceAdd(e) {
|
||||
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
|
||||
if (e.type === 'keydown') e.preventDefault();
|
||||
const sourceId = e.currentTarget.getAttribute('data-source');
|
||||
if (sourceId) { reportConfig.data_source = sourceId; addDataSourceToCanvas(sourceId); }
|
||||
}
|
||||
function handleComponentAdd(e) {
|
||||
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
|
||||
if (e.type === 'keydown') e.preventDefault();
|
||||
const compId = e.currentTarget.getAttribute('data-component');
|
||||
if (compId) addComponentToCanvas(compId);
|
||||
}
|
||||
document.querySelectorAll('#dataSources [data-source]').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const sourceId = item.getAttribute('data-source');
|
||||
if (sourceId) {
|
||||
reportConfig.data_source = sourceId;
|
||||
addDataSourceToCanvas(sourceId);
|
||||
}
|
||||
});
|
||||
item.addEventListener('click', handleSourceAdd);
|
||||
item.addEventListener('keydown', handleSourceAdd);
|
||||
});
|
||||
document.querySelectorAll('[data-component]').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const compId = item.getAttribute('data-component');
|
||||
if (compId) addComponentToCanvas(compId);
|
||||
});
|
||||
document.querySelectorAll('#components [data-component]').forEach(item => {
|
||||
item.addEventListener('click', handleComponentAdd);
|
||||
item.addEventListener('keydown', handleComponentAdd);
|
||||
});
|
||||
|
||||
canvas.addEventListener('dragover', (e) => {
|
||||
@@ -391,29 +402,35 @@ function removeDataSource() {
|
||||
}
|
||||
}
|
||||
|
||||
function addComponentToCanvas(componentId) {
|
||||
// Check if component already exists
|
||||
const existingComponent = canvas.querySelector(`[data-report-component="${componentId}"]`);
|
||||
if (existingComponent) {
|
||||
return; // Don't add duplicates
|
||||
}
|
||||
|
||||
const componentNames = {
|
||||
function getComponentDisplayName(componentId) {
|
||||
const names = {
|
||||
'table': '{{ _("Table") }}',
|
||||
'chart': '{{ _("Chart") }}',
|
||||
'chart-doughnut': '{{ _("Doughnut Chart") }}',
|
||||
'chart-bar': '{{ _("Bar Chart") }}',
|
||||
'summary': '{{ _("Summary") }}'
|
||||
};
|
||||
|
||||
return names[componentId] || componentId;
|
||||
}
|
||||
function getComponentIcon(componentId) {
|
||||
const icons = { 'table': 'table', 'chart': 'chart-line', 'chart-doughnut': 'chart-pie', 'chart-bar': 'chart-bar', 'summary': 'calculator' };
|
||||
return icons[componentId] || 'chart-line';
|
||||
}
|
||||
function addComponentToCanvas(componentId) {
|
||||
const existingComponent = canvas.querySelector(`[data-report-component="${componentId}"]`);
|
||||
if (existingComponent) return;
|
||||
const element = document.createElement('div');
|
||||
element.className = 'p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-4';
|
||||
element.setAttribute('data-report-component', componentId);
|
||||
const name = getComponentDisplayName(componentId);
|
||||
const icon = getComponentIcon(componentId);
|
||||
element.innerHTML = `
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<i class="fas fa-${componentId === 'table' ? 'table' : componentId === 'chart' ? 'chart-line' : 'calculator'} mr-2"></i>
|
||||
<strong>${componentNames[componentId] || componentId}</strong>
|
||||
<i class="fas fa-${icon} mr-2"></i>
|
||||
<strong>${name}</strong>
|
||||
</div>
|
||||
<button type="button" onclick="removeComponent('${componentId}')" class="text-red-600 hover:text-red-800">
|
||||
<button type="button" onclick="removeComponent('${componentId}')" class="text-red-600 hover:text-red-800" aria-label="{{ _('Remove') }} ${name}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,22 +4,22 @@
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Reports'}
|
||||
{'text': _('Reports')}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-chart-bar',
|
||||
title_text='Reports',
|
||||
subtitle_text='View comprehensive reports and analytics for your time tracking data',
|
||||
title_text=_('Reports'),
|
||||
subtitle_text=_('View comprehensive reports and analytics for your time tracking data'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{{ info_card("Total Hours", "%.2f"|format(summary.total_hours), "All time") }}
|
||||
{{ info_card("Billable Hours", "%.2f"|format(summary.billable_hours), "All time") }}
|
||||
{{ info_card("Active Projects", summary.active_projects, "Currently active") }}
|
||||
{{ info_card("Active Users", summary.total_users, "Currently active") }}
|
||||
{{ info_card(_("Total Hours"), "%.2f"|format(summary.total_hours), _("All time")) }}
|
||||
{{ info_card(_("Billable Hours"), "%.2f"|format(summary.billable_hours), _("All time")) }}
|
||||
{{ info_card(_("Active Projects"), summary.active_projects, _("Currently active")) }}
|
||||
{{ info_card(_("Active Users"), summary.total_users, _("Currently active")) }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
@@ -28,32 +28,32 @@
|
||||
<i class="fas fa-money-bill-wave text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold text-green-600">{{ currency|currency_symbol }}{{ "%.2f"|format(summary.total_payments) }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Total Payments</div>
|
||||
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">Last 30 days</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Total Payments') }}</div>
|
||||
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">{{ _('Last 30 days') }}</div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<i class="fas fa-receipt text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold text-blue-600">{{ summary.payment_count }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Payments Received</div>
|
||||
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">Last 30 days</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Payments Received') }}</div>
|
||||
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">{{ _('Last 30 days') }}</div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<i class="fas fa-credit-card text-amber-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold text-amber-600">{{ currency|currency_symbol }}{{ "%.2f"|format(summary.payment_fees) }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Gateway Fees</div>
|
||||
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">Last 30 days</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Gateway Fees') }}</div>
|
||||
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">{{ _('Last 30 days') }}</div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<i class="fas fa-chart-line text-emerald-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold text-emerald-600">{{ currency|currency_symbol }}{{ "%.2f"|format(summary.total_payments - summary.payment_fees) }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Net Received</div>
|
||||
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">After fees</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Net Received') }}</div>
|
||||
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">{{ _('After fees') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,25 +66,25 @@
|
||||
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Quick Date Ranges') }}</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" onclick="setDateRange('today')" class="date-preset-btn px-4 py-2 rounded-lg text-sm bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-calendar-day mr-1"></i>Today
|
||||
<i class="fas fa-calendar-day mr-1"></i>{{ _('Today') }}
|
||||
</button>
|
||||
<button type="button" onclick="setDateRange('week')" class="date-preset-btn px-4 py-2 rounded-lg text-sm bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-calendar-week mr-1"></i>This Week
|
||||
<i class="fas fa-calendar-week mr-1"></i>{{ _('This Week') }}
|
||||
</button>
|
||||
<button type="button" onclick="setDateRange('month')" class="date-preset-btn px-4 py-2 rounded-lg text-sm bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-calendar-alt mr-1"></i>This Month
|
||||
<i class="fas fa-calendar-alt mr-1"></i>{{ _('This Month') }}
|
||||
</button>
|
||||
<button type="button" onclick="setDateRange('year')" class="date-preset-btn px-4 py-2 rounded-lg text-sm bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-calendar mr-1"></i>This Year
|
||||
<i class="fas fa-calendar mr-1"></i>{{ _('This Year') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Custom Range</label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Custom Range') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="date" id="startDate" class="form-input user-date-input flex-1">
|
||||
<input type="date" id="endDate" class="form-input user-date-input flex-1">
|
||||
<button type="button" onclick="applyCustomDateRange()" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
Apply
|
||||
{{ _('Apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,8 +97,8 @@
|
||||
<button type="button" onclick="showComparisonView('month')" class="w-full px-4 py-3 rounded-lg text-left bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium text-text-light dark:text-text-dark">This Month vs Last Month</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Compare current month with previous month</div>
|
||||
<div class="font-medium text-text-light dark:text-text-dark">{{ _('This Month vs Last Month') }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Compare current month with previous month') }}</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-text-muted-light dark:text-text-muted-dark"></i>
|
||||
</div>
|
||||
@@ -106,8 +106,8 @@
|
||||
<button type="button" onclick="showComparisonView('year')" class="w-full px-4 py-3 rounded-lg text-left bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-medium text-text-light dark:text-text-dark">This Year vs Last Year</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Compare current year with previous year</div>
|
||||
<div class="font-medium text-text-light dark:text-text-dark">{{ _('This Year vs Last Year') }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Compare current year with previous year') }}</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-text-muted-light dark:text-text-muted-dark"></i>
|
||||
</div>
|
||||
@@ -136,14 +136,14 @@
|
||||
<option value="excel">Excel</option>
|
||||
</select>
|
||||
<button type="button" onclick="exportReport()" class="w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-download mr-2"></i>Export Report
|
||||
<i class="fas fa-download mr-2"></i>{{ _('Export Report') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 border border-border-light dark:border-border-dark rounded-lg">
|
||||
<h3 class="font-medium mb-2">{{ _('Scheduled Reports') }}</h3>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">Set up automatic report generation</p>
|
||||
<button type="button" onclick="showScheduledReportsModal()" class="w-full bg-secondary text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors">
|
||||
<i class="fas fa-clock mr-2"></i>Manage Scheduled Reports
|
||||
<i class="fas fa-clock mr-2"></i>{{ _('Manage Scheduled Reports') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 border border-border-light dark:border-border-dark rounded-lg">
|
||||
@@ -206,9 +206,9 @@
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2">Project</th>
|
||||
<th class="p-2">Duration</th>
|
||||
<th class="p-2">Date</th>
|
||||
<th class="p-2">{{ _('Project') }}</th>
|
||||
<th class="p-2">{{ _('Duration') }}</th>
|
||||
<th class="p-2">{{ _('Date') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -220,7 +220,18 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="p-4 text-center">No recent entries.</td>
|
||||
<td colspan="3" class="p-8">
|
||||
<div class="flex flex-col items-center justify-center text-center py-4">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-3">
|
||||
<i class="fas fa-inbox text-2xl text-text-muted-light dark:text-text-muted-dark"></i>
|
||||
</div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark font-medium mb-1">{{ _('No recent entries.') }}</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Start tracking time to see entries here.') }}</p>
|
||||
<a href="{{ url_for('timer.manual_entry') }}" class="inline-flex items-center gap-2 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-play"></i>{{ _('Log Time') }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -232,18 +243,18 @@
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">Scheduled Reports</h3>
|
||||
<h3 class="text-lg font-semibold">{{ _('Scheduled Reports') }}</h3>
|
||||
<button type="button" onclick="hideScheduledReportsModal()" class="text-text-muted-light dark:text-text-muted-dark hover:text-text-light dark:hover:text-text-dark">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="scheduledReportsList" class="space-y-3">
|
||||
<!-- Scheduled reports will be loaded here -->
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-4">No scheduled reports yet.</p>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-4">{{ _('No scheduled reports yet.') }}</p>
|
||||
</div>
|
||||
<div class="mt-6 pt-4 border-t border-border-light dark:border-border-dark">
|
||||
<button type="button" onclick="showAddScheduledReportForm()" class="w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>Add Scheduled Report
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add Scheduled Report') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -404,7 +415,7 @@ function loadScheduledReports() {
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
listContainer.innerHTML = '<p class="text-text-muted-light dark:text-text-muted-dark text-center py-4">No scheduled reports yet.</p>';
|
||||
listContainer.innerHTML = '<p class="text-text-muted-light dark:text-text-muted-dark text-center py-4">{{ _('No scheduled reports yet.') }}</p>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
@@ -77,7 +77,15 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="p-4 text-center">No data for the selected period.</td>
|
||||
<td colspan="4" class="p-8">
|
||||
<div class="flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-gray-100 dark:bg-gray-800 mb-2">
|
||||
<i class="fas fa-chart-bar text-xl text-text-muted-light dark:text-text-muted-dark"></i>
|
||||
</div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark font-medium">{{ _('No data for the selected period.') }}</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Try a different date range or ensure time has been logged on projects.') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -94,7 +94,15 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="p-4 text-center">No tasks with logged time for the selected period.</td>
|
||||
<td colspan="5" class="p-8">
|
||||
<div class="flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-gray-100 dark:bg-gray-800 mb-2">
|
||||
<i class="fas fa-tasks text-xl text-text-muted-light dark:text-text-muted-dark"></i>
|
||||
</div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark font-medium">{{ _('No tasks with logged time for the selected period.') }}</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Try a different date range or log time on tasks.') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -149,7 +149,15 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="p-4 text-center">No data for the selected period.</td>
|
||||
<td colspan="7" class="p-8">
|
||||
<div class="flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-gray-100 dark:bg-gray-800 mb-2">
|
||||
<i class="fas fa-users text-xl text-text-muted-light dark:text-text-muted-dark"></i>
|
||||
</div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark font-medium">{{ _('No data for the selected period.') }}</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Try a different date range or ensure time has been logged.') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -172,8 +172,17 @@
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{% if can_view_all %}11{% else %}10{% endif %}" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ _('No time entries found') }}
|
||||
<td colspan="{% if can_view_all %}11{% else %}10{% endif %}" class="px-4 py-12">
|
||||
<div class="flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-3">
|
||||
<i class="fas fa-clock text-2xl text-text-muted-light dark:text-text-muted-dark"></i>
|
||||
</div>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark font-medium mb-1">{{ _('No time entries found') }}</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Try adjusting your filters or log a new time entry.') }}</p>
|
||||
<a href="{{ url_for('timer.manual_entry') }}" class="inline-flex items-center gap-2 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-play"></i>{{ _('Log Time') }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
+36
-29
@@ -38,6 +38,7 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
#### 1. **One-Click Timers**
|
||||
- Start tracking time with a single click
|
||||
- Quick timer start from dashboard, projects, or tasks
|
||||
- **Header timer button** — One-click start/stop from any page (round icon between Chat and Help)
|
||||
- Visual timer display with running time
|
||||
- Multiple timer support (configurable)
|
||||
|
||||
@@ -911,13 +912,19 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
|
||||
### Navigation & Context
|
||||
|
||||
#### 101. **Breadcrumb Navigation**
|
||||
#### 101. **Header Quick Access**
|
||||
- Chat, Timer, and Help buttons grouped in the header
|
||||
- Round icon buttons, vertically aligned, evenly spaced
|
||||
- One-click timer start/stop from any page
|
||||
- Help button links to documentation; Chat opens team chat (when enabled)
|
||||
|
||||
#### 102. **Breadcrumb Navigation**
|
||||
- Context-aware breadcrumb trails
|
||||
- Quick navigation to parent pages
|
||||
- Integrated in page headers
|
||||
- Responsive breadcrumb layout
|
||||
|
||||
#### 102. **Recently Viewed & Favorites**
|
||||
#### 103. **Recently Viewed & Favorites**
|
||||
- Recently viewed items tracking
|
||||
- Favorites system for quick access
|
||||
- Quick access dropdowns
|
||||
@@ -926,21 +933,21 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
|
||||
### User Feedback & Guidance
|
||||
|
||||
#### 103. **Enhanced Empty States**
|
||||
#### 104. **Enhanced Empty States**
|
||||
- Beautiful, actionable empty states
|
||||
- Context-specific guidance
|
||||
- Quick action buttons
|
||||
- Helpful illustrations
|
||||
- Call-to-action messages
|
||||
|
||||
#### 104. **Loading States**
|
||||
#### 105. **Loading States**
|
||||
- Skeleton loading components
|
||||
- Progress indicators
|
||||
- Loading animations
|
||||
- Context-aware loading states
|
||||
- Non-blocking loading feedback
|
||||
|
||||
#### 105. **Interactive Onboarding**
|
||||
#### 106. **Interactive Onboarding**
|
||||
- Step-by-step product tours
|
||||
- Interactive tutorials
|
||||
- Element highlighting
|
||||
@@ -950,7 +957,7 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
|
||||
#### 106. **PWA Capabilities**
|
||||
#### 107. **PWA Capabilities**
|
||||
- Install as mobile app
|
||||
- Offline support
|
||||
- Background sync for time entries
|
||||
@@ -961,7 +968,7 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
|
||||
### Accessibility
|
||||
|
||||
#### 107. **Accessibility Features**
|
||||
#### 108. **Accessibility Features**
|
||||
- WCAG 2.1 AA compliant
|
||||
- Full keyboard navigation
|
||||
- Screen reader support
|
||||
@@ -977,14 +984,14 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
|
||||
### System Administration
|
||||
|
||||
#### 108. **Admin Dashboard**
|
||||
#### 109. **Admin Dashboard**
|
||||
- System overview
|
||||
- User management
|
||||
- System settings
|
||||
- Health monitoring
|
||||
- Quick statistics
|
||||
|
||||
#### 109. **System Settings**
|
||||
#### 110. **System Settings**
|
||||
- Application configuration
|
||||
- Timer settings (idle timeout, rounding)
|
||||
- User management settings
|
||||
@@ -992,55 +999,55 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
- Email configuration
|
||||
- Telemetry settings
|
||||
|
||||
#### 110. **User Management**
|
||||
#### 111. **User Management**
|
||||
- Create, edit, delete users
|
||||
- User role assignment
|
||||
- User activation/deactivation
|
||||
- User permission management
|
||||
- User activity monitoring
|
||||
|
||||
#### 111. **Backup & Restore**
|
||||
#### 112. **Backup & Restore**
|
||||
- Manual backup creation
|
||||
- Scheduled backups
|
||||
- Backup download
|
||||
- Backup restoration
|
||||
- Backup management
|
||||
|
||||
#### 112. **Logo & Branding**
|
||||
#### 113. **Logo & Branding**
|
||||
- Company logo upload
|
||||
- Logo management
|
||||
- Logo removal
|
||||
- Logo in PDF invoices
|
||||
- Logo in email templates
|
||||
|
||||
#### 113. **PDF Layout Customization**
|
||||
#### 114. **PDF Layout Customization**
|
||||
- Customizable PDF invoice layout
|
||||
- PDF template editor
|
||||
- Layout preview
|
||||
- Default layout setting
|
||||
- Layout reset
|
||||
|
||||
#### 114. **Email Configuration**
|
||||
#### 115. **Email Configuration**
|
||||
- SMTP server configuration
|
||||
- Email template management
|
||||
- Email sending test
|
||||
- Email delivery status
|
||||
- Email template editing
|
||||
|
||||
#### 115. **Telemetry Management**
|
||||
#### 116. **Telemetry Management**
|
||||
- Telemetry enable/disable
|
||||
- Telemetry data viewing
|
||||
- Privacy settings
|
||||
- Analytics configuration
|
||||
|
||||
#### 116. **Audit Logs**
|
||||
#### 117. **Audit Logs**
|
||||
- System activity logging
|
||||
- User action tracking
|
||||
- Entity change history
|
||||
- Audit log filtering
|
||||
- Audit log export
|
||||
|
||||
#### 117. **OIDC/SSO Configuration**
|
||||
#### 118. **OIDC/SSO Configuration**
|
||||
- OIDC provider setup
|
||||
- SSO configuration
|
||||
- User mapping
|
||||
@@ -1053,14 +1060,14 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
|
||||
### REST API
|
||||
|
||||
#### 118. **REST API v1**
|
||||
#### 119. **REST API v1**
|
||||
- Comprehensive REST API
|
||||
- Token-based authentication
|
||||
- JSON request/response
|
||||
- Pagination support
|
||||
- Error handling
|
||||
|
||||
#### 119. **API Endpoints**
|
||||
#### 120. **API Endpoints**
|
||||
- Projects API (CRUD)
|
||||
- Time Entries API (CRUD)
|
||||
- Tasks API (CRUD)
|
||||
@@ -1069,14 +1076,14 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
- Users API (read)
|
||||
- Reports API
|
||||
|
||||
#### 120. **API Authentication**
|
||||
#### 121. **API Authentication**
|
||||
- API token generation
|
||||
- Bearer token authentication
|
||||
- API key header authentication
|
||||
- Token scopes
|
||||
- Token permissions
|
||||
|
||||
#### 121. **API Documentation**
|
||||
#### 122. **API Documentation**
|
||||
- OpenAPI/Swagger specification
|
||||
- Interactive API docs
|
||||
- Endpoint documentation
|
||||
@@ -1085,7 +1092,7 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
|
||||
### Import/Export
|
||||
|
||||
#### 122. **Data Import**
|
||||
#### 123. **Data Import**
|
||||
- CSV import of time entries
|
||||
- Project import
|
||||
- Client import
|
||||
@@ -1093,7 +1100,7 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
- Import validation
|
||||
- Import error handling
|
||||
|
||||
#### 123. **Data Export**
|
||||
#### 124. **Data Export**
|
||||
- CSV export of all data types
|
||||
- Excel export
|
||||
- PDF export
|
||||
@@ -1107,42 +1114,42 @@ TimeTracker is a comprehensive, self-hosted time tracking and project management
|
||||
|
||||
### Deployment & Infrastructure
|
||||
|
||||
#### 124. **Docker Support**
|
||||
#### 125. **Docker Support**
|
||||
- Docker Compose configuration
|
||||
- Multiple deployment profiles
|
||||
- Production-ready setup
|
||||
- Development setup
|
||||
- Local testing setup
|
||||
|
||||
#### 125. **Database Support**
|
||||
#### 126. **Database Support**
|
||||
- PostgreSQL for production
|
||||
- SQLite for testing/development
|
||||
- Database migrations (Alembic)
|
||||
- Migration management
|
||||
- Database backup/restore
|
||||
|
||||
#### 126. **HTTPS Support**
|
||||
#### 127. **HTTPS Support**
|
||||
- Automatic HTTPS setup
|
||||
- Self-signed certificates
|
||||
- mkcert integration
|
||||
- Manual certificate setup
|
||||
- SSL/TLS configuration
|
||||
|
||||
#### 127. **Monitoring Stack**
|
||||
#### 128. **Monitoring Stack**
|
||||
- Prometheus metrics
|
||||
- Grafana dashboards
|
||||
- Loki log aggregation
|
||||
- Promtail log shipping
|
||||
- Health check endpoints
|
||||
|
||||
#### 128. **Internationalization (i18n)**
|
||||
#### 129. **Internationalization (i18n)**
|
||||
- Multiple language support
|
||||
- Translation system
|
||||
- Language switching
|
||||
- Locale-based formatting
|
||||
- Timezone handling
|
||||
|
||||
#### 129. **Progressive Web App (PWA)**
|
||||
#### 130. **Progressive Web App (PWA)**
|
||||
- Install as mobile app
|
||||
- Offline support
|
||||
- App manifest
|
||||
|
||||
Reference in New Issue
Block a user