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.
This commit is contained in:
@@ -32,73 +32,171 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats — pure typography -->
|
||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
|
||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatHours(reportData.totalSeconds) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Earnings</p>
|
||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(reportData.totalEarnings || 0) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Projects</p>
|
||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ reportData.byProject?.length || 0 }}</p>
|
||||
</div>
|
||||
<!-- Tab Buttons -->
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<button
|
||||
@click="activeTab = 'hours'"
|
||||
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
|
||||
:class="activeTab === 'hours' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<BarChart3 class="w-3.5 h-3.5" :stroke-width="1.5" />
|
||||
Hours
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'profitability'; if (profitabilityData.length === 0 && startDate && endDate) fetchProfitability()"
|
||||
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
|
||||
:class="activeTab === 'profitability' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<DollarSign class="w-3.5 h-3.5" :stroke-width="1.5" />
|
||||
Profitability
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Hours by Project</h2>
|
||||
<div class="h-64">
|
||||
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
|
||||
<div v-else class="flex flex-col items-center justify-center h-full">
|
||||
<BarChart3 class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
|
||||
<p class="text-sm text-text-secondary mt-3">Generate a report to see your data</p>
|
||||
<!-- Hours Tab -->
|
||||
<template v-if="activeTab === 'hours'">
|
||||
<!-- Summary Stats — pure typography -->
|
||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
|
||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatHours(reportData.totalSeconds) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Earnings</p>
|
||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(reportData.totalEarnings || 0) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Projects</p>
|
||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ reportData.byProject?.length || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Breakdown -->
|
||||
<div>
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Breakdown</h2>
|
||||
<!-- Chart -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Hours by Project</h2>
|
||||
<div class="h-64">
|
||||
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
|
||||
<div v-else class="flex flex-col items-center justify-center h-full">
|
||||
<BarChart3 class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
|
||||
<p class="text-sm text-text-secondary mt-3">Generate a report to see your data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="reportData.byProject && reportData.byProject.length > 0">
|
||||
<div
|
||||
v-for="projectData in reportData.byProject"
|
||||
:key="projectData.project_id"
|
||||
class="py-4 border-b border-border-subtle last:border-0"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Project Breakdown -->
|
||||
<div>
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Breakdown</h2>
|
||||
|
||||
<div v-if="reportData.byProject && reportData.byProject.length > 0">
|
||||
<div
|
||||
v-for="projectData in reportData.byProject"
|
||||
:key="projectData.project_id"
|
||||
class="py-4 border-b border-border-subtle last:border-0"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: getProjectColor(projectData.project_id) }"
|
||||
/>
|
||||
<span class="text-[0.8125rem] text-text-primary">{{ getProjectName(projectData.project_id) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatHours(projectData.total_seconds) }}</span>
|
||||
<span class="text-[0.75rem] font-mono text-text-secondary">{{ formatCurrency((projectData.total_seconds / 3600) * getProjectRate(projectData.project_id)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-bg-elevated rounded-full h-[2px]">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: getProjectColor(projectData.project_id) }"
|
||||
class="h-[2px] rounded-full"
|
||||
:style="{
|
||||
width: `${getProjectPercentage(projectData.total_seconds)}%`,
|
||||
backgroundColor: getProjectColor(projectData.project_id)
|
||||
}"
|
||||
/>
|
||||
<span class="text-[0.8125rem] text-text-primary">{{ getProjectName(projectData.project_id) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatHours(projectData.total_seconds) }}</span>
|
||||
<span class="text-[0.75rem] font-mono text-text-secondary">{{ formatCurrency((projectData.total_seconds / 3600) * getProjectRate(projectData.project_id)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-bg-elevated rounded-full h-[2px]">
|
||||
<div
|
||||
class="h-[2px] rounded-full"
|
||||
:style="{
|
||||
width: `${getProjectPercentage(projectData.total_seconds)}%`,
|
||||
backgroundColor: getProjectColor(projectData.project_id)
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-[0.75rem] text-text-tertiary py-8 text-center">
|
||||
No data for selected date range
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Profitability Tab -->
|
||||
<template v-if="activeTab === 'profitability'">
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Revenue</p>
|
||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitTotalRevenue) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
|
||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ profitTotalHours.toFixed(1) }}h</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Avg Hourly Rate</p>
|
||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitAvgRate) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue Chart -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Revenue by Project</h2>
|
||||
<div class="h-64">
|
||||
<Bar v-if="profitChartData" :data="profitChartData" :options="profitChartOptions" />
|
||||
<div v-else class="flex flex-col items-center justify-center h-full">
|
||||
<DollarSign class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
|
||||
<p class="text-sm text-text-secondary mt-3">Generate a report to see profitability data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-[0.75rem] text-text-tertiary py-8 text-center">
|
||||
No data for selected date range
|
||||
</p>
|
||||
</div>
|
||||
<!-- Project Table -->
|
||||
<div>
|
||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Per-Project Breakdown</h2>
|
||||
|
||||
<div v-if="profitabilityData.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border-subtle">
|
||||
<th class="text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Project</th>
|
||||
<th class="text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Client</th>
|
||||
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Hours</th>
|
||||
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Rate</th>
|
||||
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Revenue</th>
|
||||
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2">Budget %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in profitabilityData"
|
||||
:key="row.project_name"
|
||||
class="border-b border-border-subtle last:border-0"
|
||||
>
|
||||
<td class="py-3 pr-4 text-[0.8125rem] text-text-primary">{{ row.project_name }}</td>
|
||||
<td class="py-3 pr-4 text-[0.75rem] text-text-secondary">{{ row.client_name || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-primary">{{ row.total_hours.toFixed(1) }}h</td>
|
||||
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-secondary">{{ formatCurrency(row.hourly_rate) }}</td>
|
||||
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(row.revenue) }}</td>
|
||||
<td class="py-3 text-right text-[0.75rem] font-mono" :class="row.budget_used_pct != null && row.budget_used_pct > 100 ? 'text-status-error' : 'text-text-secondary'">
|
||||
{{ row.budget_used_pct != null ? row.budget_used_pct.toFixed(0) + '%' : '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-[0.75rem] text-text-tertiary py-8 text-center">
|
||||
No data for selected date range
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<ProfitabilityRow[]>([])
|
||||
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const reportData = ref<ReportData>({
|
||||
@@ -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<ProfitabilityRow[]>('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) {
|
||||
|
||||
Reference in New Issue
Block a user