Files
zeroclock/src/views/Reports.vue

534 lines
18 KiB
Vue

<template>
<div class="p-6">
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Reports</h1>
<!-- Date Range Selector -->
<div class="bg-bg-surface rounded-lg p-4 mb-6 flex flex-wrap items-end gap-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Start Date</label>
<AppDatePicker
v-model="startDate"
placeholder="Start date"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">End Date</label>
<AppDatePicker
v-model="endDate"
placeholder="End date"
/>
</div>
<button
@click="fetchReport"
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
Generate
</button>
<button
@click="exportCSV"
class="px-4 py-2 border border-border-visible text-text-primary text-xs rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Export CSV
</button>
</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>
<!-- 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>
<!-- 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>
<!-- 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="h-[2px] rounded-full"
:style="{
width: `${getProjectPercentage(projectData.total_seconds)}%`,
backgroundColor: getProjectColor(projectData.project_id)
}"
/>
</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>
</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>
<!-- 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>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Bar } from 'vue-chartjs'
import { BarChart3, DollarSign } from 'lucide-vue-next'
import AppDatePicker from '../components/AppDatePicker.vue'
import { useToastStore } from '../stores/toast'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js'
import { useEntriesStore } from '../stores/entries'
import { useProjectsStore } from '../stores/projects'
import { formatCurrency } from '../utils/locale'
// Register Chart.js components
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const entriesStore = useEntriesStore()
const projectsStore = useProjectsStore()
const toastStore = useToastStore()
interface ProjectReport {
project_id: number
total_seconds: number
}
interface ReportData {
totalSeconds: number
totalEarnings?: number
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>({
totalSeconds: 0,
totalEarnings: 0,
byProject: []
})
// Total hours for percentage calculation
const totalHours = computed(() => {
return reportData.value.totalSeconds / 3600
})
// Get project percentage
function getProjectPercentage(projectSeconds: number): number {
if (totalHours.value === 0) return 0
return (projectSeconds / 3600 / totalHours.value) * 100
}
// Chart data
const chartData = computed(() => {
if (!reportData.value.byProject || reportData.value.byProject.length === 0) {
return null
}
const labels = reportData.value.byProject.map(p => getProjectName(p.project_id))
const data = reportData.value.byProject.map(p => p.total_seconds / 3600)
const colors = reportData.value.byProject.map(p => getProjectColor(p.project_id))
return {
labels,
datasets: [
{
label: 'Hours',
data,
backgroundColor: colors,
borderRadius: 2
}
]
}
})
// Chart options
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context: { raw: unknown }) => {
const hours = context.raw as number
return `${hours.toFixed(1)} hours`
}
}
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: '#2E2E2A'
},
ticks: {
color: '#5A5A54'
}
},
x: {
grid: {
display: false
},
ticks: {
color: '#5A5A54'
}
}
}
}
// Format hours
function formatHours(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return `${minutes}m`
}
// Get project name by ID
function getProjectName(projectId: number): string {
const project = projectsStore.projects.find(p => p.id === projectId)
return project?.name || 'Unknown Project'
}
// Get project color by ID
function getProjectColor(projectId: number): string {
const project = projectsStore.projects.find(p => p.id === projectId)
return project?.color || '#6B7280'
}
// Get project hourly rate
function getProjectRate(projectId: number): number {
const project = projectsStore.projects.find(p => p.id === projectId)
return project?.hourly_rate || 0
}
// Fetch report data
async function fetchReport() {
if (!startDate.value || !endDate.value) {
toastStore.info('Please select a date range')
return
}
try {
const data = await invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
startDate: startDate.value,
endDate: endDate.value
})
// Calculate total earnings
let totalEarnings = 0
if (data.byProject && Array.isArray(data.byProject)) {
(data.byProject as ProjectReport[]).forEach((p: ProjectReport) => {
const rate = getProjectRate(p.project_id)
totalEarnings += (p.total_seconds / 3600) * rate
})
}
reportData.value = {
totalSeconds: data.totalSeconds,
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) {
toastStore.info('No data to export')
return
}
// CSV header
let csv = 'Project,Hours,Rate,Earnings\n'
// Add rows
reportData.value.byProject.forEach((p: ProjectReport) => {
const name = getProjectName(p.project_id)
const hours = (p.total_seconds / 3600).toFixed(2)
const rate = getProjectRate(p.project_id)
const rateFixed = rate.toFixed(2)
const earnings = ((p.total_seconds / 3600) * rate).toFixed(2)
csv += `"${name}",${hours},${rateFixed},${earnings}\n`
})
// Add totals
csv += `\nTotal,${(reportData.value.totalSeconds / 3600).toFixed(2)},,${reportData.value.totalEarnings?.toFixed(2) || '0.00'}\n`
// Download file
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `time-report-${startDate.value}-to-${endDate.value}.csv`
a.click()
URL.revokeObjectURL(url)
}
// Set default dates on mount
onMounted(async () => {
await projectsStore.fetchProjects()
await entriesStore.fetchEntries()
// Set default to this month
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
startDate.value = monthStart.toISOString().split('T')[0]
endDate.value = now.toISOString().split('T')[0]
// Fetch initial report
fetchReport()
})
</script>