feat: time-of-day heatmap in reports patterns tab
This commit is contained in:
@@ -3,7 +3,13 @@
|
|||||||
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Reports</h1>
|
<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 -->
|
<!-- Date Range Selector -->
|
||||||
<div class="bg-bg-surface rounded-lg p-4 mb-6 flex flex-wrap items-end gap-4">
|
<div data-tour-id="reports-daterange" class="bg-bg-surface rounded-lg p-4 mb-6 flex flex-wrap items-end gap-4">
|
||||||
|
<AppDateRangePresets
|
||||||
|
:start-date="startDate"
|
||||||
|
:end-date="endDate"
|
||||||
|
@select="({ start, end }) => { startDate = start; endDate = end; fetchReport() }"
|
||||||
|
class="mb-3 w-full"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Start Date</label>
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Start Date</label>
|
||||||
<AppDatePicker
|
<AppDatePicker
|
||||||
@@ -18,7 +24,17 @@
|
|||||||
placeholder="End date"
|
placeholder="End date"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Billable</label>
|
||||||
|
<AppSelect
|
||||||
|
v-model="billableFilter"
|
||||||
|
:options="billableFilterOptions"
|
||||||
|
label-key="label"
|
||||||
|
value-key="value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
|
data-tour-id="reports-generate"
|
||||||
@click="fetchReport"
|
@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"
|
class="px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
|
||||||
>
|
>
|
||||||
@@ -33,55 +49,111 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Buttons -->
|
<!-- Tab Buttons -->
|
||||||
<div class="flex items-center gap-2 mb-6">
|
<div data-tour-id="reports-tabs" class="flex items-center gap-2 mb-6" role="tablist" aria-label="Report type" @keydown="onTabKeydown">
|
||||||
<button
|
<button
|
||||||
@click="activeTab = 'hours'"
|
@click="activeTab = 'hours'"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === 'hours'"
|
||||||
|
aria-controls="tabpanel-hours"
|
||||||
|
id="tab-hours"
|
||||||
|
:tabindex="activeTab === 'hours' ? 0 : -1"
|
||||||
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
|
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'"
|
:class="activeTab === 'hours' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
<BarChart3 class="w-3.5 h-3.5" :stroke-width="1.5" />
|
<BarChart3 class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
|
||||||
Hours
|
Hours
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="activeTab = 'profitability'; if (profitabilityData.length === 0 && startDate && endDate) fetchProfitability()"
|
@click="activeTab = 'profitability'; if (profitabilityData.length === 0 && startDate && endDate) fetchProfitability()"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === 'profitability'"
|
||||||
|
aria-controls="tabpanel-profitability"
|
||||||
|
id="tab-profitability"
|
||||||
|
:tabindex="activeTab === 'profitability' ? 0 : -1"
|
||||||
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
|
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'"
|
:class="activeTab === 'profitability' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
<DollarSign class="w-3.5 h-3.5" :stroke-width="1.5" />
|
<DollarSign class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
|
||||||
Profitability
|
Profitability
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="switchToExpenses"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === 'expenses'"
|
||||||
|
aria-controls="tabpanel-expenses"
|
||||||
|
id="tab-expenses"
|
||||||
|
:tabindex="activeTab === 'expenses' ? 0 : -1"
|
||||||
|
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
|
||||||
|
:class="activeTab === 'expenses' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<Receipt class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
|
||||||
|
Expenses
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'patterns'; if (!patternsLoaded) computePatterns()"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === 'patterns'"
|
||||||
|
aria-controls="tabpanel-patterns"
|
||||||
|
id="tab-patterns"
|
||||||
|
:tabindex="activeTab === 'patterns' ? 0 : -1"
|
||||||
|
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
|
||||||
|
:class="activeTab === 'patterns' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<Grid3x3 class="w-3.5 h-3.5" :stroke-width="1.5" aria-hidden="true" />
|
||||||
|
Patterns
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hours Tab -->
|
<!-- Hours Tab -->
|
||||||
<template v-if="activeTab === 'hours'">
|
<template v-if="activeTab === 'hours'">
|
||||||
|
<div id="tabpanel-hours" role="tabpanel" aria-labelledby="tab-hours" tabindex="0">
|
||||||
<!-- Summary Stats - pure typography -->
|
<!-- Summary Stats - pure typography -->
|
||||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
<dl class="grid grid-cols-3 gap-6 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</dt>
|
||||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatHours(reportData.totalSeconds) }}</p>
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatHours(filteredTotalSeconds) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Earnings</p>
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Earnings</dt>
|
||||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(reportData.totalEarnings || 0) }}</p>
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(filteredTotalEarnings) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Projects</p>
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Projects</dt>
|
||||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ reportData.byProject?.length || 0 }}</p>
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ filteredByProject.length }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Billable split -->
|
||||||
|
<div class="flex items-center gap-4 text-[0.75rem] text-text-secondary mb-8">
|
||||||
|
<span>Billable: <span class="font-mono text-accent-text">{{ formatHours(billableSeconds) }}</span></span>
|
||||||
|
<span class="text-border-subtle">|</span>
|
||||||
|
<span>Non-billable: <span class="font-mono text-text-tertiary">{{ formatHours(nonBillableSeconds) }}</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chart -->
|
<!-- Chart -->
|
||||||
<div class="mb-8">
|
<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>
|
<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">
|
<div class="h-64" aria-label="Bar chart showing hours by project">
|
||||||
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
|
<Bar v-if="chartData" :data="chartData" :options="chartOptions" />
|
||||||
<div v-else class="flex flex-col items-center justify-center h-full">
|
<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" />
|
<BarChart3 class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" aria-hidden="true" />
|
||||||
<p class="text-sm text-text-secondary mt-3">Generate a report to see your data</p>
|
<p class="text-sm text-text-secondary mt-3">Generate a report to see your data</p>
|
||||||
|
<p class="text-xs text-text-tertiary mt-1">Select a date range and click Generate.</p>
|
||||||
|
<button
|
||||||
|
v-if="onboardingStore.isVisible"
|
||||||
|
@click="tourStore.start(TOURS.reports)"
|
||||||
|
class="mt-2 text-[0.6875rem] text-accent-text hover:underline"
|
||||||
|
>
|
||||||
|
Show me how
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,9 +162,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Breakdown</h2>
|
<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-if="filteredByProject.length > 0">
|
||||||
<div
|
<div
|
||||||
v-for="projectData in reportData.byProject"
|
v-for="projectData in filteredByProject"
|
||||||
:key="projectData.project_id"
|
:key="projectData.project_id"
|
||||||
class="py-4 border-b border-border-subtle last:border-0"
|
class="py-4 border-b border-border-subtle last:border-0"
|
||||||
>
|
>
|
||||||
@@ -101,6 +173,7 @@
|
|||||||
<div
|
<div
|
||||||
class="w-2 h-2 rounded-full shrink-0"
|
class="w-2 h-2 rounded-full shrink-0"
|
||||||
:style="{ backgroundColor: getProjectColor(projectData.project_id) }"
|
:style="{ backgroundColor: getProjectColor(projectData.project_id) }"
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="text-[0.8125rem] text-text-primary">{{ getProjectName(projectData.project_id) }}</span>
|
<span class="text-[0.8125rem] text-text-primary">{{ getProjectName(projectData.project_id) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,6 +184,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-bg-elevated rounded-full h-[2px]">
|
<div class="w-full bg-bg-elevated rounded-full h-[2px]">
|
||||||
<div
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
:aria-valuenow="Math.round(getProjectPercentage(projectData.total_seconds))"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
:aria-label="getProjectName(projectData.project_id) + ' progress'"
|
||||||
class="h-[2px] rounded-full"
|
class="h-[2px] rounded-full"
|
||||||
:style="{
|
:style="{
|
||||||
width: `${getProjectPercentage(projectData.total_seconds)}%`,
|
width: `${getProjectPercentage(projectData.total_seconds)}%`,
|
||||||
@@ -125,34 +203,37 @@
|
|||||||
No data for selected date range
|
No data for selected date range
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Profitability Tab -->
|
<!-- Profitability Tab -->
|
||||||
<template v-if="activeTab === 'profitability'">
|
<template v-if="activeTab === 'profitability'">
|
||||||
|
<div id="tabpanel-profitability" role="tabpanel" aria-labelledby="tab-profitability" tabindex="0">
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
<dl class="grid grid-cols-3 gap-6 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Revenue</p>
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Revenue</dt>
|
||||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitTotalRevenue) }}</p>
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitTotalRevenue) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</dt>
|
||||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ profitTotalHours.toFixed(1) }}h</p>
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ profitTotalHours.toFixed(1) }}h</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Avg Hourly Rate</p>
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Avg Hourly Rate</dt>
|
||||||
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitAvgRate) }}</p>
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitAvgRate) }}</dd>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<!-- Revenue Chart -->
|
<!-- Revenue Chart -->
|
||||||
<div class="mb-8">
|
<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>
|
<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">
|
<div class="h-64" aria-label="Horizontal bar chart showing revenue by project">
|
||||||
<Bar v-if="profitChartData" :data="profitChartData" :options="profitChartOptions" />
|
<Bar v-if="profitChartData" :data="profitChartData" :options="profitChartOptions" />
|
||||||
<div v-else class="flex flex-col items-center justify-center h-full">
|
<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" />
|
<DollarSign class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" aria-hidden="true" />
|
||||||
<p class="text-sm text-text-secondary mt-3">Generate a report to see profitability data</p>
|
<p class="text-sm text-text-secondary mt-3">Generate a report to see profitability data</p>
|
||||||
|
<p class="text-xs text-text-tertiary mt-1">Select a date range and click Generate.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,16 +277,219 @@
|
|||||||
No data for selected date range
|
No data for selected date range
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Patterns Tab -->
|
||||||
|
<template v-if="activeTab === 'patterns'">
|
||||||
|
<div id="tabpanel-patterns" role="tabpanel" aria-labelledby="tab-patterns" tabindex="0" class="space-y-6">
|
||||||
|
<!-- Summary stats -->
|
||||||
|
<div v-if="patternsLoaded && hasPatternData" class="flex items-center gap-6 text-[0.75rem] text-text-secondary">
|
||||||
|
<span v-if="peakDay">Most productive: <strong class="text-text-primary">{{ peakDay }}</strong></span>
|
||||||
|
<span v-if="peakHour">Peak hour: <strong class="text-text-primary">{{ peakHour }}</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View toggle -->
|
||||||
|
<div v-if="patternsLoaded && hasPatternData" class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="heatmapViewMode = 'grid'"
|
||||||
|
:class="heatmapViewMode === 'grid' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||||
|
class="px-3 py-1 text-[0.6875rem] rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
Grid
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="heatmapViewMode = 'table'"
|
||||||
|
:class="heatmapViewMode === 'table' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||||
|
class="px-3 py-1 text-[0.6875rem] rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
Data Table
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid view -->
|
||||||
|
<div v-if="patternsLoaded && hasPatternData && heatmapViewMode === 'grid'" class="overflow-x-auto">
|
||||||
|
<div class="inline-grid gap-0.5" :style="{ gridTemplateColumns: 'auto repeat(24, minmax(2rem, 1fr))' }" role="grid" aria-label="Time-of-day activity heatmap">
|
||||||
|
<!-- Header row -->
|
||||||
|
<div class="p-1" role="columnheader"></div>
|
||||||
|
<div v-for="h in 24" :key="'h-' + h" class="p-1 text-[0.5625rem] text-text-tertiary text-center" role="columnheader">
|
||||||
|
{{ h - 1 }}
|
||||||
|
</div>
|
||||||
|
<!-- Data rows -->
|
||||||
|
<template v-for="(dayData, dayIdx) in heatmapData" :key="'d-' + dayIdx">
|
||||||
|
<div class="p-1 text-[0.625rem] text-text-secondary" role="rowheader">
|
||||||
|
{{ dayNames[dayIdx] }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(value, hourIdx) in dayData"
|
||||||
|
:key="'c-' + dayIdx + '-' + hourIdx"
|
||||||
|
role="gridcell"
|
||||||
|
:aria-label="dayNames[dayIdx] + ' ' + hourIdx + ':00 - ' + (value > 0 ? value.toFixed(1) + ' hours' : 'no activity')"
|
||||||
|
class="p-1 text-center text-[0.5625rem] rounded-sm transition-colors"
|
||||||
|
:style="{ backgroundColor: getHeatColor(value) }"
|
||||||
|
:class="value > 0 ? 'text-text-primary' : 'text-transparent'"
|
||||||
|
>
|
||||||
|
{{ value > 0 ? value.toFixed(1) : '' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table view -->
|
||||||
|
<div v-if="patternsLoaded && hasPatternData && heatmapViewMode === 'table'" class="overflow-x-auto">
|
||||||
|
<table class="w-full text-[0.6875rem]">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border-subtle">
|
||||||
|
<th scope="col" class="px-2 py-1 text-left text-text-tertiary">Day</th>
|
||||||
|
<th v-for="h in 24" :key="h" scope="col" class="px-1 py-1 text-center text-text-tertiary">{{ h - 1 }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(dayData, dayIdx) in heatmapData" :key="dayIdx" class="border-b border-border-subtle">
|
||||||
|
<th scope="row" class="px-2 py-1 text-left text-text-secondary">{{ dayNames[dayIdx] }}</th>
|
||||||
|
<td v-for="(value, hourIdx) in dayData" :key="hourIdx" class="px-1 py-1 text-center"
|
||||||
|
:class="value > 0 ? 'text-accent-text' : 'text-text-tertiary'">
|
||||||
|
{{ value > 0 ? value.toFixed(1) : '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="patternsLoaded && !hasPatternData" class="py-12 text-center">
|
||||||
|
<Grid3x3 class="w-10 h-10 text-text-tertiary mx-auto mb-3" :stroke-width="1.5" aria-hidden="true" />
|
||||||
|
<p class="text-[0.8125rem] text-text-secondary">No time data for the selected period</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state before patterns computed -->
|
||||||
|
<div v-if="!patternsLoaded" class="py-12 text-center">
|
||||||
|
<Grid3x3 class="w-10 h-10 text-text-tertiary mx-auto mb-3" :stroke-width="1.5" aria-hidden="true" />
|
||||||
|
<p class="text-[0.8125rem] text-text-secondary">Select a date range and click Generate to see patterns</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Expenses Tab -->
|
||||||
|
<template v-if="activeTab === 'expenses'">
|
||||||
|
<div id="tabpanel-expenses" role="tabpanel" aria-labelledby="tab-expenses" tabindex="0">
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<dl class="grid grid-cols-3 gap-6 mb-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Expenses</dt>
|
||||||
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(expTotalAmount) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Number of Expenses</dt>
|
||||||
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ expensesData.length }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Top Category</dt>
|
||||||
|
<dd class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium capitalize">{{ expTopCategory || '-' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Invoiced vs un-invoiced split -->
|
||||||
|
<div class="flex items-center gap-4 text-[0.75rem] text-text-secondary mb-8">
|
||||||
|
<span>Invoiced: <span class="font-mono text-accent-text">{{ formatCurrency(expInvoicedAmount) }}</span></span>
|
||||||
|
<span class="text-border-subtle">|</span>
|
||||||
|
<span>Un-invoiced: <span class="font-mono text-text-tertiary">{{ formatCurrency(expUninvoicedAmount) }}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- By Category Breakdown -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Expenses by Category</h2>
|
||||||
|
|
||||||
|
<div v-if="expByCategory.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="cat in expByCategory"
|
||||||
|
:key="cat.category"
|
||||||
|
class="py-4 border-b border-border-subtle last:border-0"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-[0.8125rem] text-text-primary capitalize">{{ cat.category }}</span>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(cat.amount) }}</span>
|
||||||
|
<span class="text-[0.75rem] font-mono text-text-tertiary w-12 text-right">{{ cat.percentage.toFixed(0) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-bg-elevated rounded-full h-[2px]">
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
:aria-valuenow="Math.round(cat.percentage)"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
:aria-label="cat.category + ' expense percentage'"
|
||||||
|
class="h-[2px] rounded-full bg-accent"
|
||||||
|
:style="{ width: `${cat.percentage}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="text-[0.75rem] text-text-tertiary py-8 text-center">
|
||||||
|
No expense data for selected date range
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- By Project Breakdown -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Expenses by Project</h2>
|
||||||
|
|
||||||
|
<div v-if="expByProject.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="proj in expByProject"
|
||||||
|
:key="proj.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(proj.project_id) }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="text-[0.8125rem] text-text-primary">{{ getProjectName(proj.project_id) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(proj.amount) }}</span>
|
||||||
|
<span class="text-[0.75rem] font-mono text-text-tertiary w-12 text-right">{{ proj.percentage.toFixed(0) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-bg-elevated rounded-full h-[2px]">
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
:aria-valuenow="Math.round(proj.percentage)"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
:aria-label="getProjectName(proj.project_id) + ' expense percentage'"
|
||||||
|
class="h-[2px] rounded-full"
|
||||||
|
:style="{
|
||||||
|
width: `${proj.percentage}%`,
|
||||||
|
backgroundColor: getProjectColor(proj.project_id)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="text-[0.75rem] text-text-tertiary py-8 text-center">
|
||||||
|
No expense data for selected date range
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { Bar } from 'vue-chartjs'
|
import { Bar } from 'vue-chartjs'
|
||||||
import { BarChart3, DollarSign } from 'lucide-vue-next'
|
import { BarChart3, DollarSign, Receipt, Grid3x3 } from 'lucide-vue-next'
|
||||||
import AppDatePicker from '../components/AppDatePicker.vue'
|
import AppDatePicker from '../components/AppDatePicker.vue'
|
||||||
|
import AppDateRangePresets from '../components/AppDateRangePresets.vue'
|
||||||
|
import AppSelect from '../components/AppSelect.vue'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@@ -217,15 +501,25 @@ import {
|
|||||||
Legend
|
Legend
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import { useEntriesStore } from '../stores/entries'
|
import { useEntriesStore } from '../stores/entries'
|
||||||
|
import { useExpensesStore, type Expense } from '../stores/expenses'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { formatCurrency } from '../utils/locale'
|
import { formatCurrency } from '../utils/locale'
|
||||||
|
import { getChartTheme, buildBarChartOptions } from '../utils/chartTheme'
|
||||||
|
import { useOnboardingStore } from '../stores/onboarding'
|
||||||
|
import { useTourStore } from '../stores/tour'
|
||||||
|
import { TOURS } from '../utils/tours'
|
||||||
|
|
||||||
// Register Chart.js components
|
// Register Chart.js components
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||||
|
|
||||||
const entriesStore = useEntriesStore()
|
const entriesStore = useEntriesStore()
|
||||||
|
const expensesStore = useExpensesStore()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const onboardingStore = useOnboardingStore()
|
||||||
|
const tourStore = useTourStore()
|
||||||
|
|
||||||
interface ProjectReport {
|
interface ProjectReport {
|
||||||
project_id: number
|
project_id: number
|
||||||
@@ -248,8 +542,56 @@ interface ProfitabilityRow {
|
|||||||
budget_used_pct: number | null
|
budget_used_pct: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTab = ref<'hours' | 'profitability'>('hours')
|
const activeTab = ref<'hours' | 'profitability' | 'expenses' | 'patterns'>('hours')
|
||||||
const profitabilityData = ref<ProfitabilityRow[]>([])
|
const profitabilityData = ref<ProfitabilityRow[]>([])
|
||||||
|
const expensesData = ref<Expense[]>([])
|
||||||
|
|
||||||
|
function onTabKeydown(e: KeyboardEvent) {
|
||||||
|
const tabs: Array<'hours' | 'profitability' | 'expenses' | 'patterns'> = ['hours', 'profitability', 'expenses', 'patterns']
|
||||||
|
const current = tabs.indexOf(activeTab.value)
|
||||||
|
let next = current
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
next = (current + 1) % tabs.length
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
next = (current - 1 + tabs.length) % tabs.length
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault()
|
||||||
|
next = 0
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault()
|
||||||
|
next = tabs.length - 1
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeTab.value = tabs[next]
|
||||||
|
if (tabs[next] === 'profitability' && profitabilityData.value.length === 0 && startDate.value && endDate.value) {
|
||||||
|
fetchProfitability()
|
||||||
|
}
|
||||||
|
if (tabs[next] === 'expenses' && expensesData.value.length === 0 && startDate.value && endDate.value) {
|
||||||
|
fetchExpensesReport()
|
||||||
|
}
|
||||||
|
if (tabs[next] === 'patterns' && !patternsLoaded.value) {
|
||||||
|
computePatterns()
|
||||||
|
}
|
||||||
|
const nextTabEl = document.getElementById(`tab-${tabs[next]}`)
|
||||||
|
nextTabEl?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToExpenses() {
|
||||||
|
activeTab.value = 'expenses'
|
||||||
|
if (expensesData.value.length === 0 && startDate.value && endDate.value) {
|
||||||
|
fetchExpensesReport()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const billableFilter = ref<string>('all')
|
||||||
|
const billableFilterOptions = [
|
||||||
|
{ label: 'All entries', value: 'all' },
|
||||||
|
{ label: 'Billable only', value: 'billable' },
|
||||||
|
{ label: 'Non-billable only', value: 'non-billable' },
|
||||||
|
]
|
||||||
|
|
||||||
const startDate = ref('')
|
const startDate = ref('')
|
||||||
const endDate = ref('')
|
const endDate = ref('')
|
||||||
@@ -259,9 +601,54 @@ const reportData = ref<ReportData>({
|
|||||||
byProject: []
|
byProject: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Billable split totals computed from entries
|
||||||
|
const billableSeconds = computed(() => {
|
||||||
|
return entriesStore.entries
|
||||||
|
.filter(e => (e.billable ?? 1) === 1)
|
||||||
|
.reduce((sum, e) => sum + e.duration, 0)
|
||||||
|
})
|
||||||
|
const nonBillableSeconds = computed(() => {
|
||||||
|
return entriesStore.entries
|
||||||
|
.filter(e => e.billable === 0)
|
||||||
|
.reduce((sum, e) => sum + e.duration, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filtered report data based on billable filter
|
||||||
|
const filteredByProject = computed(() => {
|
||||||
|
if (billableFilter.value === 'all') return reportData.value.byProject || []
|
||||||
|
|
||||||
|
const isBillable = billableFilter.value === 'billable'
|
||||||
|
const filteredEntries = entriesStore.entries.filter(e =>
|
||||||
|
isBillable ? (e.billable ?? 1) === 1 : e.billable === 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const projectMap = new Map<number, number>()
|
||||||
|
for (const e of filteredEntries) {
|
||||||
|
projectMap.set(e.project_id, (projectMap.get(e.project_id) || 0) + e.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(projectMap.entries()).map(([project_id, total_seconds]) => ({
|
||||||
|
project_id,
|
||||||
|
total_seconds,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTotalSeconds = computed(() => {
|
||||||
|
if (billableFilter.value === 'all') return reportData.value.totalSeconds
|
||||||
|
return filteredByProject.value.reduce((sum, p) => sum + p.total_seconds, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTotalEarnings = computed(() => {
|
||||||
|
if (billableFilter.value === 'all') return reportData.value.totalEarnings || 0
|
||||||
|
return filteredByProject.value.reduce((sum, p) => {
|
||||||
|
const rate = getProjectRate(p.project_id)
|
||||||
|
return sum + (p.total_seconds / 3600) * rate
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
// Total hours for percentage calculation
|
// Total hours for percentage calculation
|
||||||
const totalHours = computed(() => {
|
const totalHours = computed(() => {
|
||||||
return reportData.value.totalSeconds / 3600
|
return filteredTotalSeconds.value / 3600
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get project percentage
|
// Get project percentage
|
||||||
@@ -270,15 +657,22 @@ function getProjectPercentage(projectSeconds: number): number {
|
|||||||
return (projectSeconds / 3600 / totalHours.value) * 100
|
return (projectSeconds / 3600 / totalHours.value) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chart theme state
|
||||||
|
const chartTheme = ref(getChartTheme())
|
||||||
|
|
||||||
|
function refreshChartTheme() {
|
||||||
|
chartTheme.value = getChartTheme()
|
||||||
|
}
|
||||||
|
|
||||||
// Chart data
|
// Chart data
|
||||||
const chartData = computed(() => {
|
const chartData = computed(() => {
|
||||||
if (!reportData.value.byProject || reportData.value.byProject.length === 0) {
|
if (filteredByProject.value.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const labels = reportData.value.byProject.map(p => getProjectName(p.project_id))
|
const labels = filteredByProject.value.map(p => getProjectName(p.project_id))
|
||||||
const data = reportData.value.byProject.map(p => p.total_seconds / 3600)
|
const data = filteredByProject.value.map(p => p.total_seconds / 3600)
|
||||||
const colors = reportData.value.byProject.map(p => getProjectColor(p.project_id))
|
const colors = filteredByProject.value.map(p => getProjectColor(p.project_id))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels,
|
labels,
|
||||||
@@ -293,15 +687,23 @@ const chartData = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Chart options
|
// Chart options (theme-aware)
|
||||||
const chartOptions = {
|
const chartOptions = computed(() => {
|
||||||
responsive: true,
|
const theme = chartTheme.value
|
||||||
maintainAspectRatio: false,
|
const base = buildBarChartOptions(theme)
|
||||||
plugins: {
|
return {
|
||||||
legend: {
|
...base,
|
||||||
display: false
|
scales: {
|
||||||
|
...base.scales,
|
||||||
|
y: {
|
||||||
|
...base.scales.y,
|
||||||
|
beginAtZero: true,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
...base.plugins,
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
...base.plugins.tooltip,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (context: { raw: unknown }) => {
|
label: (context: { raw: unknown }) => {
|
||||||
const hours = context.raw as number
|
const hours = context.raw as number
|
||||||
@@ -309,27 +711,9 @@ const chartOptions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: {
|
|
||||||
color: '#2E2E2A'
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
color: '#5A5A54'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
grid: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
color: '#5A5A54'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// Format hours
|
// Format hours
|
||||||
function formatHours(seconds: number): string {
|
function formatHours(seconds: number): string {
|
||||||
@@ -367,6 +751,12 @@ async function fetchReport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Reset patterns when fetching new report
|
||||||
|
patternsLoaded.value = false
|
||||||
|
|
||||||
|
// Fetch entries for billable split calculations
|
||||||
|
await entriesStore.fetchEntries(startDate.value, endDate.value)
|
||||||
|
|
||||||
const data = await invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
|
const data = await invoke<{ totalSeconds: number; byProject: unknown[] }>('get_reports', {
|
||||||
startDate: startDate.value,
|
startDate: startDate.value,
|
||||||
endDate: endDate.value
|
endDate: endDate.value
|
||||||
@@ -391,6 +781,16 @@ async function fetchReport() {
|
|||||||
if (activeTab.value === 'profitability') {
|
if (activeTab.value === 'profitability') {
|
||||||
await fetchProfitability()
|
await fetchProfitability()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also fetch expenses if on that tab
|
||||||
|
if (activeTab.value === 'expenses') {
|
||||||
|
await fetchExpensesReport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also recompute patterns if on that tab
|
||||||
|
if (activeTab.value === 'patterns') {
|
||||||
|
computePatterns()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch report:', error)
|
console.error('Failed to fetch report:', error)
|
||||||
toastStore.error('Failed to generate report')
|
toastStore.error('Failed to generate report')
|
||||||
@@ -426,16 +826,30 @@ const profitChartData = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Profitability chart options
|
// Profitability chart options (theme-aware)
|
||||||
const profitChartOptions = {
|
const profitChartOptions = computed(() => {
|
||||||
responsive: true,
|
const theme = chartTheme.value
|
||||||
maintainAspectRatio: false,
|
const base = buildBarChartOptions(theme)
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
indexAxis: 'y' as const,
|
indexAxis: 'y' as const,
|
||||||
plugins: {
|
scales: {
|
||||||
legend: {
|
x: {
|
||||||
display: false
|
beginAtZero: true,
|
||||||
|
ticks: { color: theme.textTertiary, font: { size: 11 } },
|
||||||
|
grid: { color: theme.gridColor },
|
||||||
|
border: { display: false },
|
||||||
},
|
},
|
||||||
|
y: {
|
||||||
|
ticks: { color: theme.textTertiary, font: { size: 11 } },
|
||||||
|
grid: { display: false },
|
||||||
|
border: { display: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
...base.plugins,
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
...base.plugins.tooltip,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (context: { raw: unknown }) => {
|
label: (context: { raw: unknown }) => {
|
||||||
const val = context.raw as number
|
const val = context.raw as number
|
||||||
@@ -443,27 +857,9 @@ const profitChartOptions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: {
|
|
||||||
color: '#2E2E2A'
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
color: '#5A5A54'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
grid: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
color: '#5A5A54'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch profitability data
|
// Fetch profitability data
|
||||||
async function fetchProfitability() {
|
async function fetchProfitability() {
|
||||||
@@ -483,6 +879,150 @@ async function fetchProfitability() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// EXPENSES TAB
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Fetch expenses for report
|
||||||
|
async function fetchExpensesReport() {
|
||||||
|
if (!startDate.value || !endDate.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expensesStore.fetchExpenses(undefined, startDate.value, endDate.value)
|
||||||
|
expensesData.value = [...expensesStore.expenses]
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch expenses for report:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expense summary computeds
|
||||||
|
const expTotalAmount = computed(() => {
|
||||||
|
return expensesData.value.reduce((sum, e) => sum + e.amount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const expInvoicedAmount = computed(() => {
|
||||||
|
return expensesData.value.filter(e => e.invoiced).reduce((sum, e) => sum + e.amount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const expUninvoicedAmount = computed(() => {
|
||||||
|
return expensesData.value.filter(e => !e.invoiced).reduce((sum, e) => sum + e.amount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const expTopCategory = computed(() => {
|
||||||
|
if (expensesData.value.length === 0) return ''
|
||||||
|
const catMap = new Map<string, number>()
|
||||||
|
for (const e of expensesData.value) {
|
||||||
|
catMap.set(e.category, (catMap.get(e.category) || 0) + e.amount)
|
||||||
|
}
|
||||||
|
let topCat = ''
|
||||||
|
let topAmount = 0
|
||||||
|
for (const [cat, amount] of catMap) {
|
||||||
|
if (amount > topAmount) {
|
||||||
|
topCat = cat
|
||||||
|
topAmount = amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return topCat
|
||||||
|
})
|
||||||
|
|
||||||
|
// By category breakdown
|
||||||
|
const expByCategory = computed(() => {
|
||||||
|
if (expensesData.value.length === 0) return []
|
||||||
|
const total = expTotalAmount.value
|
||||||
|
if (total === 0) return []
|
||||||
|
|
||||||
|
const catMap = new Map<string, number>()
|
||||||
|
for (const e of expensesData.value) {
|
||||||
|
catMap.set(e.category, (catMap.get(e.category) || 0) + e.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(catMap.entries())
|
||||||
|
.map(([category, amount]) => ({
|
||||||
|
category,
|
||||||
|
amount,
|
||||||
|
percentage: (amount / total) * 100
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.amount - a.amount)
|
||||||
|
})
|
||||||
|
|
||||||
|
// By project breakdown
|
||||||
|
const expByProject = computed(() => {
|
||||||
|
if (expensesData.value.length === 0) return []
|
||||||
|
const total = expTotalAmount.value
|
||||||
|
if (total === 0) return []
|
||||||
|
|
||||||
|
const projMap = new Map<number, number>()
|
||||||
|
for (const e of expensesData.value) {
|
||||||
|
projMap.set(e.project_id, (projMap.get(e.project_id) || 0) + e.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(projMap.entries())
|
||||||
|
.map(([project_id, amount]) => ({
|
||||||
|
project_id,
|
||||||
|
amount,
|
||||||
|
percentage: (amount / total) * 100
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.amount - a.amount)
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// PATTERNS TAB
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
const heatmapData = ref<number[][]>([])
|
||||||
|
const patternsLoaded = ref(false)
|
||||||
|
const heatmapViewMode = ref<'grid' | 'table'>('grid')
|
||||||
|
|
||||||
|
const hasPatternData = computed(() => heatmapData.value.some(row => row.some(v => v > 0)))
|
||||||
|
|
||||||
|
const peakDay = computed(() => {
|
||||||
|
if (!heatmapData.value.length) return null
|
||||||
|
const totals = heatmapData.value.map(row => row.reduce((a, b) => a + b, 0))
|
||||||
|
const maxVal = Math.max(...totals)
|
||||||
|
const maxIdx = totals.indexOf(maxVal)
|
||||||
|
return maxVal > 0 ? dayNames[maxIdx] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const peakHour = computed(() => {
|
||||||
|
if (!heatmapData.value.length) return null
|
||||||
|
let maxVal = 0
|
||||||
|
let maxHour = 0
|
||||||
|
for (const row of heatmapData.value) {
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
if (row[h] > maxVal) {
|
||||||
|
maxVal = row[h]
|
||||||
|
maxHour = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxVal > 0 ? `${maxHour}:00` : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function computePatterns() {
|
||||||
|
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0))
|
||||||
|
|
||||||
|
for (const entry of entriesStore.entries) {
|
||||||
|
if (!entry.start_time) continue
|
||||||
|
const d = new Date(entry.start_time)
|
||||||
|
const jsDay = d.getDay()
|
||||||
|
const day = jsDay === 0 ? 6 : jsDay - 1 // Mon=0, Sun=6
|
||||||
|
const hour = d.getHours()
|
||||||
|
grid[day][hour] += entry.duration / 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
heatmapData.value = grid
|
||||||
|
patternsLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeatColor(value: number): string {
|
||||||
|
if (value === 0) return 'transparent'
|
||||||
|
const maxVal = Math.max(...heatmapData.value.flat(), 1)
|
||||||
|
const intensity = Math.min(value / maxVal, 1)
|
||||||
|
const alpha = 0.1 + intensity * 0.6
|
||||||
|
return `rgba(245, 158, 11, ${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
// Export to CSV
|
// Export to CSV
|
||||||
function exportCSV() {
|
function exportCSV() {
|
||||||
if (!reportData.value.byProject || reportData.value.byProject.length === 0) {
|
if (!reportData.value.byProject || reportData.value.byProject.length === 0) {
|
||||||
@@ -516,10 +1056,23 @@ function exportCSV() {
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recreate charts on theme or accent change
|
||||||
|
watch(() => settingsStore.settings.theme_mode, () => {
|
||||||
|
nextTick(() => { refreshChartTheme() })
|
||||||
|
})
|
||||||
|
watch(() => settingsStore.settings.accent_color, () => {
|
||||||
|
nextTick(() => { refreshChartTheme() })
|
||||||
|
})
|
||||||
|
|
||||||
// Set default dates on mount
|
// Set default dates on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await projectsStore.fetchProjects()
|
await Promise.all([
|
||||||
await entriesStore.fetchEntries()
|
projectsStore.fetchProjects(),
|
||||||
|
entriesStore.fetchEntries(),
|
||||||
|
settingsStore.fetchSettings(),
|
||||||
|
])
|
||||||
|
|
||||||
|
refreshChartTheme()
|
||||||
|
|
||||||
// Set default to this month
|
// Set default to this month
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
Reference in New Issue
Block a user