From e143b069db87fd62caa4474f5eace8d61f6c3637 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 10:46:10 +0200 Subject: [PATCH] feat: add profitability tab and favorites strip Reports view now has Hours/Profitability tabs with per-project revenue table. Timer view shows favorites strip for quick project selection and a Save as Favorite button next to the description input. --- src/views/Reports.vue | 310 ++++++++++++++++++++++++++++++++++-------- src/views/Timer.vue | 97 ++++++++++--- 2 files changed, 337 insertions(+), 70 deletions(-) diff --git a/src/views/Reports.vue b/src/views/Reports.vue index 5a1d7d8..3d2a532 100644 --- a/src/views/Reports.vue +++ b/src/views/Reports.vue @@ -32,73 +32,171 @@ - -
-
-

Total Hours

-

{{ formatHours(reportData.totalSeconds) }}

-
-
-

Earnings

-

{{ formatCurrency(reportData.totalEarnings || 0) }}

-
-
-

Projects

-

{{ reportData.byProject?.length || 0 }}

-
+ +
+ +
- -
-

Hours by Project

-
- -
- -

Generate a report to see your data

+ + + + +
@@ -106,7 +204,7 @@ import { ref, computed, onMounted } from 'vue' import { invoke } from '@tauri-apps/api/core' import { Bar } from 'vue-chartjs' -import { BarChart3 } from 'lucide-vue-next' +import { BarChart3, DollarSign } from 'lucide-vue-next' import AppDatePicker from '../components/AppDatePicker.vue' import { useToastStore } from '../stores/toast' import { @@ -140,6 +238,19 @@ interface ReportData { byProject: ProjectReport[] } +interface ProfitabilityRow { + project_name: string + client_name: string | null + total_hours: number + hourly_rate: number + revenue: number + budget_hours: number | null + budget_used_pct: number | null +} + +const activeTab = ref<'hours' | 'profitability'>('hours') +const profitabilityData = ref([]) + const startDate = ref('') const endDate = ref('') const reportData = ref({ @@ -275,12 +386,103 @@ async function fetchReport() { totalEarnings, byProject: data.byProject as ProjectReport[] } + + // Also fetch profitability if on that tab + if (activeTab.value === 'profitability') { + await fetchProfitability() + } } catch (error) { console.error('Failed to fetch report:', error) toastStore.error('Failed to generate report') } } +// Profitability summary stats +const profitTotalRevenue = computed(() => profitabilityData.value.reduce((sum, r) => sum + r.revenue, 0)) +const profitTotalHours = computed(() => profitabilityData.value.reduce((sum, r) => sum + r.total_hours, 0)) +const profitAvgRate = computed(() => profitTotalHours.value > 0 ? profitTotalRevenue.value / profitTotalHours.value : 0) + +// Profitability chart data +const profitChartData = computed(() => { + if (profitabilityData.value.length === 0) return null + + const labels = profitabilityData.value.map(r => r.project_name) + const data = profitabilityData.value.map(r => r.revenue) + const colors = profitabilityData.value.map((r) => { + const project = projectsStore.projects.find(p => p.name === r.project_name) + return project?.color || '#6B7280' + }) + + return { + labels, + datasets: [ + { + label: 'Revenue', + data, + backgroundColor: colors, + borderRadius: 2 + } + ] + } +}) + +// Profitability chart options +const profitChartOptions = { + responsive: true, + maintainAspectRatio: false, + indexAxis: 'y' as const, + plugins: { + legend: { + display: false + }, + tooltip: { + callbacks: { + label: (context: { raw: unknown }) => { + const val = context.raw as number + return formatCurrency(val) + } + } + } + }, + scales: { + x: { + beginAtZero: true, + grid: { + color: '#2E2E2A' + }, + ticks: { + color: '#5A5A54' + } + }, + y: { + grid: { + display: false + }, + ticks: { + color: '#5A5A54' + } + } + } +} + +// Fetch profitability data +async function fetchProfitability() { + if (!startDate.value || !endDate.value) { + toastStore.info('Please select a date range') + return + } + + try { + profitabilityData.value = await invoke('get_profitability_report', { + startDate: startDate.value, + endDate: endDate.value + }) + } catch (error) { + console.error('Failed to fetch profitability report:', error) + toastStore.error('Failed to generate profitability report') + } +} + // Export to CSV function exportCSV() { if (!reportData.value.byProject || reportData.value.byProject.length === 0) { diff --git a/src/views/Timer.vue b/src/views/Timer.vue index 240e8bc..c4d6b51 100644 --- a/src/views/Timer.vue +++ b/src/views/Timer.vue @@ -2,13 +2,23 @@
-

- {{ timerParts.hours }} - : - {{ timerParts.minutes }} - : - {{ timerParts.seconds }} -

+
+

+ {{ timerParts.hours }} + : + {{ timerParts.minutes }} + : + {{ timerParts.seconds }} +

+ +

+ +
+
+ +
+
+
@@ -59,13 +86,23 @@
- +
+ + +
@@ -143,17 +180,21 @@ import { useProjectsStore, type Task } from '../stores/projects' import { useEntriesStore } from '../stores/entries' import { useSettingsStore } from '../stores/settings' import { useToastStore } from '../stores/toast' -import { Timer as TimerIcon, RotateCcw } from 'lucide-vue-next' +import { Timer as TimerIcon, RotateCcw, ExternalLink, Star } from 'lucide-vue-next' +import { invoke } from '@tauri-apps/api/core' import AppSelect from '../components/AppSelect.vue' import IdlePromptDialog from '../components/IdlePromptDialog.vue' import AppTrackingPromptDialog from '../components/AppTrackingPromptDialog.vue' import { formatDateTime } from '../utils/locale' +import { useFavoritesStore, type Favorite } from '../stores/favorites' const timerStore = useTimerStore() const projectsStore = useProjectsStore() const entriesStore = useEntriesStore() const settingsStore = useSettingsStore() const toastStore = useToastStore() +const favoritesStore = useFavoritesStore() +const favorites = computed(() => favoritesStore.favorites) // Local state for inputs const selectedProject = ref(timerStore.selectedProjectId) @@ -253,6 +294,11 @@ watch(() => timerStore.timerState, async (newState, oldState) => { } }) +// Open mini timer pop-out +async function openMiniTimer() { + await invoke('open_mini_timer') +} + // Toggle timer function toggleTimer() { if (timerStore.isStopped) { @@ -298,6 +344,24 @@ function repeatEntry(entry: { project_id: number; task_id?: number; description? description.value = entry.description || '' } +// Apply a favorite to the inputs +function applyFavorite(fav: Favorite) { + selectedProject.value = fav.project_id + selectedTask.value = fav.task_id || null + description.value = fav.description || '' +} + +// Save current inputs as a favorite +async function saveAsFavorite() { + if (!selectedProject.value) return + await favoritesStore.createFavorite({ + project_id: selectedProject.value, + task_id: selectedTask.value || undefined, + description: description.value || undefined, + sort_order: favoritesStore.favorites.length, + }) +} + // Get project name by ID function getProjectName(projectId: number): string { const project = projectsStore.projects.find(p => p.id === projectId) @@ -325,7 +389,8 @@ onMounted(async () => { await Promise.all([ projectsStore.fetchProjects(), entriesStore.fetchEntries(), - settingsStore.fetchSettings() + settingsStore.fetchSettings(), + favoritesStore.fetchFavorites() ]) // Restore timer state