Heading font: Plus Jakarta Sans (500/600/700) for all h1-h3, stat values, dialog titles, timer display, and wordmark. Body font: Inter (400/500/600/700) unchanged but base bumped from 13px to 14px. Mono font: JetBrains Mono replaces IBM Plex Mono for code and tabular data.
332 lines
10 KiB
Vue
332 lines
10 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>
|
|
<input
|
|
v-model="startDate"
|
|
type="date"
|
|
class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">End Date</label>
|
|
<input
|
|
v-model="endDate"
|
|
type="date"
|
|
class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
|
/>
|
|
</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>
|
|
|
|
<!-- 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">${{ reportData.totalEarnings?.toFixed(2) || '0.00' }}</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">${{ ((projectData.total_seconds / 3600) * getProjectRate(projectData.project_id)).toFixed(2) }}</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>
|
|
</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 } from 'lucide-vue-next'
|
|
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'
|
|
|
|
// 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[]
|
|
}
|
|
|
|
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[]
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch report:', error)
|
|
toastStore.error('Failed to generate 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>
|