This commit is contained in:
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -1459,8 +1459,8 @@ packages:
|
|||||||
base64-js@1.5.1:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.15:
|
baseline-browser-mapping@2.9.16:
|
||||||
resolution: {integrity: sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==}
|
resolution: {integrity: sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
bcryptjs@3.0.3:
|
bcryptjs@3.0.3:
|
||||||
@@ -5264,7 +5264,7 @@ snapshots:
|
|||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.15: {}
|
baseline-browser-mapping@2.9.16: {}
|
||||||
|
|
||||||
bcryptjs@3.0.3: {}
|
bcryptjs@3.0.3: {}
|
||||||
|
|
||||||
@@ -5319,7 +5319,7 @@ snapshots:
|
|||||||
|
|
||||||
browserslist@4.28.1:
|
browserslist@4.28.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
baseline-browser-mapping: 2.9.15
|
baseline-browser-mapping: 2.9.16
|
||||||
caniuse-lite: 1.0.30001765
|
caniuse-lite: 1.0.30001765
|
||||||
electron-to-chromium: 1.5.267
|
electron-to-chromium: 1.5.267
|
||||||
node-releases: 2.0.27
|
node-releases: 2.0.27
|
||||||
|
|||||||
67
src/components/TagChart.vue
Normal file
67
src/components/TagChart.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div style="position: relative; height: 100%; width: 100%">
|
||||||
|
<Doughnut :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { Doughnut } from "vue-chartjs";
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
ArcElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
type ChartOptions,
|
||||||
|
} from "chart.js";
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface TagData {
|
||||||
|
name: string;
|
||||||
|
totalTime: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tags: TagData[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: props.tags.map((t) => t.name),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: props.tags.map((t) => t.totalTime / (1000 * 60)), // Convert to minutes
|
||||||
|
backgroundColor: props.tags.map((t) => t.color),
|
||||||
|
borderColor: "#1e293b", // Matches typical dark mode bg
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chartOptions: ChartOptions<"doughnut"> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: "right",
|
||||||
|
labels: {
|
||||||
|
color: "#e2e8f0",
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context) {
|
||||||
|
const minutes = Math.round(context.raw as number);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return ` ${hours}h ${mins}m`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cutout: "70%",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../../../db';
|
import { db } from '../../../../db';
|
||||||
import { clients, timeEntries, members, categories, users } from '../../../../db/schema';
|
import { clients, timeEntries, members, tags, timeEntryTags, users } from '../../../../db/schema';
|
||||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
import { eq, and, desc, sql, inArray } from 'drizzle-orm';
|
||||||
import { formatTimeRange } from '../../../../lib/formatTime';
|
import { formatTimeRange } from '../../../../lib/formatTime';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
@@ -38,19 +38,44 @@ const client = await db.select()
|
|||||||
if (!client) return Astro.redirect('/dashboard/clients');
|
if (!client) return Astro.redirect('/dashboard/clients');
|
||||||
|
|
||||||
// Get recent activity
|
// Get recent activity
|
||||||
const recentEntries = await db.select({
|
const recentEntriesData = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
category: categories,
|
|
||||||
user: users,
|
user: users,
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
|
|
||||||
.leftJoin(users, eq(timeEntries.userId, users.id))
|
.leftJoin(users, eq(timeEntries.userId, users.id))
|
||||||
.where(eq(timeEntries.clientId, client.id))
|
.where(eq(timeEntries.clientId, client.id))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
// Fetch tags for these entries
|
||||||
|
const entryIds = recentEntriesData.map(e => e.entry.id);
|
||||||
|
const tagsMap = new Map<string, { name: string; color: string | null }[]>();
|
||||||
|
|
||||||
|
if (entryIds.length > 0) {
|
||||||
|
const entryTagsData = await db.select({
|
||||||
|
timeEntryId: timeEntryTags.timeEntryId,
|
||||||
|
tag: tags
|
||||||
|
})
|
||||||
|
.from(timeEntryTags)
|
||||||
|
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
|
||||||
|
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const item of entryTagsData) {
|
||||||
|
if (!tagsMap.has(item.timeEntryId)) {
|
||||||
|
tagsMap.set(item.timeEntryId, []);
|
||||||
|
}
|
||||||
|
tagsMap.get(item.timeEntryId)!.push(item.tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentEntries = recentEntriesData.map(e => ({
|
||||||
|
...e,
|
||||||
|
tags: tagsMap.get(e.entry.id) || []
|
||||||
|
}));
|
||||||
|
|
||||||
// Calculate total time tracked
|
// Calculate total time tracked
|
||||||
const totalTimeResult = await db.select({
|
const totalTimeResult = await db.select({
|
||||||
totalDuration: sql<number>`sum(CASE WHEN ${timeEntries.endTime} IS NOT NULL THEN ${timeEntries.endTime} - ${timeEntries.startTime} ELSE 0 END)`
|
totalDuration: sql<number>`sum(CASE WHEN ${timeEntries.endTime} IS NOT NULL THEN ${timeEntries.endTime} - ${timeEntries.startTime} ELSE 0 END)`
|
||||||
@@ -181,23 +206,27 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Category</th>
|
<th>Tags</th>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recentEntries.map(({ entry, category, user: entryUser }) => (
|
{recentEntries.map(({ entry, tags, user: entryUser }) => (
|
||||||
<tr>
|
<tr>
|
||||||
<td>{entry.description || '-'}</td>
|
<td>{entry.description || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
{category ? (
|
<div class="flex flex-wrap gap-1">
|
||||||
<div class="flex items-center gap-2">
|
{tags.length > 0 ? tags.map(tag => (
|
||||||
<span class="w-2 h-2 rounded-full" style={`background-color: ${category.color}`}></span>
|
<div class="badge badge-sm badge-outline flex items-center gap-1">
|
||||||
<span>{category.name}</span>
|
{tag.color && (
|
||||||
|
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
|
||||||
|
)}
|
||||||
|
<span>{tag.name}</span>
|
||||||
|
</div>
|
||||||
|
)) : '-'}
|
||||||
</div>
|
</div>
|
||||||
) : '-'}
|
|
||||||
</td>
|
</td>
|
||||||
<td>{entryUser?.name || 'Unknown'}</td>
|
<td>{entryUser?.name || 'Unknown'}</td>
|
||||||
<td>{entry.startTime.toLocaleDateString()}</td>
|
<td>{entry.startTime.toLocaleDateString()}</td>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { organizations, members, timeEntries, clients, categories } from '../../db/schema';
|
import { organizations, members, timeEntries, clients, tags, timeEntryTags } from '../../db/schema';
|
||||||
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
import { eq, desc, and, isNull, gte, sql, inArray } from 'drizzle-orm';
|
||||||
import { formatDuration } from '../../lib/formatTime';
|
import { formatDuration } from '../../lib/formatTime';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
@@ -84,18 +84,42 @@ if (currentOrg) {
|
|||||||
|
|
||||||
stats.totalClients = clientCount?.count || 0;
|
stats.totalClients = clientCount?.count || 0;
|
||||||
|
|
||||||
stats.recentEntries = await db.select({
|
const recentEntriesData = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
client: clients,
|
client: clients,
|
||||||
category: categories,
|
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
|
||||||
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
|
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.limit(5)
|
.limit(5)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
const entryIds = recentEntriesData.map(e => e.entry.id);
|
||||||
|
const tagsMap = new Map<string, { name: string; color: string | null }[]>();
|
||||||
|
|
||||||
|
if (entryIds.length > 0) {
|
||||||
|
const entryTagsData = await db.select({
|
||||||
|
timeEntryId: timeEntryTags.timeEntryId,
|
||||||
|
tag: tags
|
||||||
|
})
|
||||||
|
.from(timeEntryTags)
|
||||||
|
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
|
||||||
|
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const item of entryTagsData) {
|
||||||
|
if (!tagsMap.has(item.timeEntryId)) {
|
||||||
|
tagsMap.set(item.timeEntryId, []);
|
||||||
|
}
|
||||||
|
tagsMap.get(item.timeEntryId)!.push(item.tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.recentEntries = recentEntriesData.map(e => ({
|
||||||
|
...e,
|
||||||
|
tags: tagsMap.get(e.entry.id) || []
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMembership = userOrgs.length > 0;
|
const hasMembership = userOrgs.length > 0;
|
||||||
@@ -205,11 +229,16 @@ const hasMembership = userOrgs.length > 0;
|
|||||||
</h2>
|
</h2>
|
||||||
{stats.recentEntries.length > 0 ? (
|
{stats.recentEntries.length > 0 ? (
|
||||||
<ul class="space-y-3 mt-4">
|
<ul class="space-y-3 mt-4">
|
||||||
{stats.recentEntries.map(({ entry, client, category }) => (
|
{stats.recentEntries.map(({ entry, client, tags }) => (
|
||||||
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${category.color || '#3b82f6'}`}>
|
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${tags[0]?.color || '#3b82f6'}`}>
|
||||||
<div class="font-semibold text-sm">{client.name}</div>
|
<div class="font-semibold text-sm">{client.name}</div>
|
||||||
<div class="text-xs text-base-content/60 mt-1">
|
<div class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-2 items-center">
|
||||||
{category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}
|
<span class="flex gap-1 flex-wrap">
|
||||||
|
{tags.length > 0 ? tags.map((tag: any) => (
|
||||||
|
<span class="badge badge-xs badge-outline">{tag.name}</span>
|
||||||
|
)) : <span class="italic opacity-50">No tags</span>}
|
||||||
|
</span>
|
||||||
|
<span>• {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import CategoryChart from '../../components/CategoryChart.vue';
|
import TagChart from '../../components/TagChart.vue';
|
||||||
import ClientChart from '../../components/ClientChart.vue';
|
import ClientChart from '../../components/ClientChart.vue';
|
||||||
import MemberChart from '../../components/MemberChart.vue';
|
import MemberChart from '../../components/MemberChart.vue';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { timeEntries, members, users, clients, categories, invoices } from '../../db/schema';
|
import { timeEntries, members, users, clients, tags, timeEntryTags, invoices } from '../../db/schema';
|
||||||
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
import { eq, and, gte, lte, sql, desc, inArray, exists } from 'drizzle-orm';
|
||||||
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
@@ -37,9 +37,9 @@ const teamMembers = await db.select({
|
|||||||
.where(eq(members.organizationId, userMembership.organizationId))
|
.where(eq(members.organizationId, userMembership.organizationId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const allCategories = await db.select()
|
const allTags = await db.select()
|
||||||
.from(categories)
|
.from(tags)
|
||||||
.where(eq(categories.organizationId, userMembership.organizationId))
|
.where(eq(tags.organizationId, userMembership.organizationId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const allClients = await db.select()
|
const allClients = await db.select()
|
||||||
@@ -49,7 +49,7 @@ const allClients = await db.select()
|
|||||||
|
|
||||||
const url = new URL(Astro.request.url);
|
const url = new URL(Astro.request.url);
|
||||||
const selectedMemberId = url.searchParams.get('member') || '';
|
const selectedMemberId = url.searchParams.get('member') || '';
|
||||||
const selectedCategoryId = url.searchParams.get('category') || '';
|
const selectedTagId = url.searchParams.get('tag') || '';
|
||||||
const selectedClientId = url.searchParams.get('client') || '';
|
const selectedClientId = url.searchParams.get('client') || '';
|
||||||
const timeRange = url.searchParams.get('range') || 'week';
|
const timeRange = url.searchParams.get('range') || 'week';
|
||||||
const customFrom = url.searchParams.get('from');
|
const customFrom = url.searchParams.get('from');
|
||||||
@@ -102,28 +102,65 @@ if (selectedMemberId) {
|
|||||||
conditions.push(eq(timeEntries.userId, selectedMemberId));
|
conditions.push(eq(timeEntries.userId, selectedMemberId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCategoryId) {
|
if (selectedTagId) {
|
||||||
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
|
conditions.push(exists(
|
||||||
|
db.select()
|
||||||
|
.from(timeEntryTags)
|
||||||
|
.where(and(
|
||||||
|
eq(timeEntryTags.timeEntryId, timeEntries.id),
|
||||||
|
eq(timeEntryTags.tagId, selectedTagId)
|
||||||
|
))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedClientId) {
|
if (selectedClientId) {
|
||||||
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = await db.select({
|
const entriesData = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
user: users,
|
user: users,
|
||||||
client: clients,
|
client: clients,
|
||||||
category: categories,
|
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.innerJoin(users, eq(timeEntries.userId, users.id))
|
.innerJoin(users, eq(timeEntries.userId, users.id))
|
||||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
// Fetch tags for these entries
|
||||||
|
const entryIds = entriesData.map(e => e.entry.id);
|
||||||
|
const tagsMap = new Map<string, typeof allTags>();
|
||||||
|
|
||||||
|
if (entryIds.length > 0) {
|
||||||
|
// Process in chunks if too many entries, but for now simple inArray
|
||||||
|
// Sqlite has limits on variables, but usually ~999. Assuming reasonable page size or volume.
|
||||||
|
// If entryIds is massive, this might fail, but for a dashboard report it's usually acceptable or needs pagination/limits.
|
||||||
|
// However, `inArray` can be empty, so we checked length.
|
||||||
|
|
||||||
|
const entryTagsData = await db.select({
|
||||||
|
timeEntryId: timeEntryTags.timeEntryId,
|
||||||
|
tag: tags
|
||||||
|
})
|
||||||
|
.from(timeEntryTags)
|
||||||
|
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
|
||||||
|
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const item of entryTagsData) {
|
||||||
|
if (!tagsMap.has(item.timeEntryId)) {
|
||||||
|
tagsMap.set(item.timeEntryId, []);
|
||||||
|
}
|
||||||
|
tagsMap.get(item.timeEntryId)!.push(item.tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = entriesData.map(e => ({
|
||||||
|
...e,
|
||||||
|
tags: tagsMap.get(e.entry.id) || []
|
||||||
|
}));
|
||||||
|
|
||||||
const statsByMember = teamMembers.map(member => {
|
const statsByMember = teamMembers.map(member => {
|
||||||
const memberEntries = entries.filter(e => e.user.id === member.id);
|
const memberEntries = entries.filter(e => e.user.id === member.id);
|
||||||
const totalTime = memberEntries.reduce((sum, e) => {
|
const totalTime = memberEntries.reduce((sum, e) => {
|
||||||
@@ -140,9 +177,9 @@ const statsByMember = teamMembers.map(member => {
|
|||||||
};
|
};
|
||||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||||
|
|
||||||
const statsByCategory = allCategories.map(category => {
|
const statsByTag = allTags.map(tag => {
|
||||||
const categoryEntries = entries.filter(e => e.category.id === category.id);
|
const tagEntries = entries.filter(e => e.tags.some(t => t.id === tag.id));
|
||||||
const totalTime = categoryEntries.reduce((sum, e) => {
|
const totalTime = tagEntries.reduce((sum, e) => {
|
||||||
if (e.entry.endTime) {
|
if (e.entry.endTime) {
|
||||||
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
||||||
}
|
}
|
||||||
@@ -150,9 +187,9 @@ const statsByCategory = allCategories.map(category => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
category,
|
tag,
|
||||||
totalTime,
|
totalTime,
|
||||||
entryCount: categoryEntries.length,
|
entryCount: tagEntries.length,
|
||||||
};
|
};
|
||||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||||
|
|
||||||
@@ -336,14 +373,14 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label font-medium" for="reports-category">
|
<label class="label font-medium" for="reports-tag">
|
||||||
Category
|
Tag
|
||||||
</label>
|
</label>
|
||||||
<select id="reports-category" name="category" class="select select-bordered" onchange="this.form.submit()">
|
<select id="reports-tag" name="tag" class="select select-bordered" onchange="this.form.submit()">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Tags</option>
|
||||||
{allCategories.map(category => (
|
{allTags.map(tag => (
|
||||||
<option value={category.id} selected={selectedCategoryId === category.id}>
|
<option value={tag.id} selected={selectedTagId === tag.id}>
|
||||||
{category.name}
|
{tag.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -546,21 +583,21 @@ function getTimeRangeLabel(range: string) {
|
|||||||
{totalTime > 0 && (
|
{totalTime > 0 && (
|
||||||
<>
|
<>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
{/* Category Distribution Chart - Only show when no category filter */}
|
{/* Tag Distribution Chart - Only show when no tag filter */}
|
||||||
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
|
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">
|
<h2 class="card-title mb-4">
|
||||||
<Icon name="heroicons:chart-pie" class="w-6 h-6" />
|
<Icon name="heroicons:chart-pie" class="w-6 h-6" />
|
||||||
Category Distribution
|
Tag Distribution
|
||||||
</h2>
|
</h2>
|
||||||
<div class="h-64 w-full">
|
<div class="h-64 w-full">
|
||||||
<CategoryChart
|
<TagChart
|
||||||
client:visible
|
client:visible
|
||||||
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
tags={statsByTag.filter(s => s.totalTime > 0).map(s => ({
|
||||||
name: s.category.name,
|
name: s.tag.name,
|
||||||
totalTime: s.totalTime,
|
totalTime: s.totalTime,
|
||||||
color: s.category.color || '#3b82f6'
|
color: s.tag.color || '#3b82f6'
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -654,33 +691,33 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats by Category - Only show if there's data and no category filter */}
|
{/* Stats by Tag - Only show if there's data and no tag filter */}
|
||||||
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
|
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">
|
<h2 class="card-title mb-4">
|
||||||
<Icon name="heroicons:tag" class="w-6 h-6" />
|
<Icon name="heroicons:tag" class="w-6 h-6" />
|
||||||
By Category
|
By Tag
|
||||||
</h2>
|
</h2>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Category</th>
|
<th>Tag</th>
|
||||||
<th>Total Time</th>
|
<th>Total Time</th>
|
||||||
<th>Entries</th>
|
<th>Entries</th>
|
||||||
<th>% of Total</th>
|
<th>% of Total</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{statsByCategory.filter(s => s.totalTime > 0).map(stat => (
|
{statsByTag.filter(s => s.totalTime > 0).map(stat => (
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{stat.category.color && (
|
{stat.tag.color && (
|
||||||
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.category.color}`}></span>
|
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.tag.color}`}></span>
|
||||||
)}
|
)}
|
||||||
<span>{stat.category.name}</span>
|
<span>{stat.tag.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||||
@@ -774,7 +811,7 @@ function getTimeRangeLabel(range: string) {
|
|||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Member</th>
|
<th>Member</th>
|
||||||
<th>Client</th>
|
<th>Client</th>
|
||||||
<th>Category</th>
|
<th>Tags</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -791,11 +828,16 @@ function getTimeRangeLabel(range: string) {
|
|||||||
<td>{e.user.name}</td>
|
<td>{e.user.name}</td>
|
||||||
<td>{e.client.name}</td>
|
<td>{e.client.name}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-wrap gap-1">
|
||||||
{e.category.color && (
|
{e.tags.map(tag => (
|
||||||
<span class="w-3 h-3 rounded-full" style={`background-color: ${e.category.color}`}></span>
|
<div class="badge badge-sm badge-outline flex items-center gap-1">
|
||||||
|
{tag.color && (
|
||||||
|
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
|
||||||
)}
|
)}
|
||||||
<span>{e.category.name}</span>
|
<span>{tag.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{e.tags.length === 0 && <span class="opacity-50">-</span>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{e.entry.description || '-'}</td>
|
<td>{e.entry.description || '-'}</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user