This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
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 MemberChart from '../../components/MemberChart.vue';
|
||||
import { db } from '../../db';
|
||||
import { timeEntries, members, users, clients, categories, invoices } from '../../db/schema';
|
||||
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||
import { timeEntries, members, users, clients, tags, timeEntryTags, invoices } from '../../db/schema';
|
||||
import { eq, and, gte, lte, sql, desc, inArray, exists } from 'drizzle-orm';
|
||||
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
@@ -37,9 +37,9 @@ const teamMembers = await db.select({
|
||||
.where(eq(members.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
|
||||
const allCategories = await db.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.organizationId, userMembership.organizationId))
|
||||
const allTags = await db.select()
|
||||
.from(tags)
|
||||
.where(eq(tags.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
|
||||
const allClients = await db.select()
|
||||
@@ -49,7 +49,7 @@ const allClients = await db.select()
|
||||
|
||||
const url = new URL(Astro.request.url);
|
||||
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 timeRange = url.searchParams.get('range') || 'week';
|
||||
const customFrom = url.searchParams.get('from');
|
||||
@@ -102,28 +102,65 @@ if (selectedMemberId) {
|
||||
conditions.push(eq(timeEntries.userId, selectedMemberId));
|
||||
}
|
||||
|
||||
if (selectedCategoryId) {
|
||||
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
|
||||
if (selectedTagId) {
|
||||
conditions.push(exists(
|
||||
db.select()
|
||||
.from(timeEntryTags)
|
||||
.where(and(
|
||||
eq(timeEntryTags.timeEntryId, timeEntries.id),
|
||||
eq(timeEntryTags.tagId, selectedTagId)
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
if (selectedClientId) {
|
||||
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
||||
}
|
||||
|
||||
const entries = await db.select({
|
||||
const entriesData = await db.select({
|
||||
entry: timeEntries,
|
||||
user: users,
|
||||
client: clients,
|
||||
category: categories,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.innerJoin(users, eq(timeEntries.userId, users.id))
|
||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(timeEntries.startTime))
|
||||
.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 memberEntries = entries.filter(e => e.user.id === member.id);
|
||||
const totalTime = memberEntries.reduce((sum, e) => {
|
||||
@@ -140,9 +177,9 @@ const statsByMember = teamMembers.map(member => {
|
||||
};
|
||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
const statsByCategory = allCategories.map(category => {
|
||||
const categoryEntries = entries.filter(e => e.category.id === category.id);
|
||||
const totalTime = categoryEntries.reduce((sum, e) => {
|
||||
const statsByTag = allTags.map(tag => {
|
||||
const tagEntries = entries.filter(e => e.tags.some(t => t.id === tag.id));
|
||||
const totalTime = tagEntries.reduce((sum, e) => {
|
||||
if (e.entry.endTime) {
|
||||
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
||||
}
|
||||
@@ -150,9 +187,9 @@ const statsByCategory = allCategories.map(category => {
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
category,
|
||||
tag,
|
||||
totalTime,
|
||||
entryCount: categoryEntries.length,
|
||||
entryCount: tagEntries.length,
|
||||
};
|
||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
@@ -336,14 +373,14 @@ function getTimeRangeLabel(range: string) {
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label font-medium" for="reports-category">
|
||||
Category
|
||||
<label class="label font-medium" for="reports-tag">
|
||||
Tag
|
||||
</label>
|
||||
<select id="reports-category" name="category" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Categories</option>
|
||||
{allCategories.map(category => (
|
||||
<option value={category.id} selected={selectedCategoryId === category.id}>
|
||||
{category.name}
|
||||
<select id="reports-tag" name="tag" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Tags</option>
|
||||
{allTags.map(tag => (
|
||||
<option value={tag.id} selected={selectedTagId === tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -546,21 +583,21 @@ function getTimeRangeLabel(range: string) {
|
||||
{totalTime > 0 && (
|
||||
<>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* Category Distribution Chart - Only show when no category filter */}
|
||||
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
|
||||
{/* Tag Distribution Chart - Only show when no tag filter */}
|
||||
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:chart-pie" class="w-6 h-6" />
|
||||
Category Distribution
|
||||
Tag Distribution
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<CategoryChart
|
||||
<TagChart
|
||||
client:visible
|
||||
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.category.name,
|
||||
tags={statsByTag.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.tag.name,
|
||||
totalTime: s.totalTime,
|
||||
color: s.category.color || '#3b82f6'
|
||||
color: s.tag.color || '#3b82f6'
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
@@ -654,33 +691,33 @@ function getTimeRangeLabel(range: string) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats by Category - Only show if there's data and no category filter */}
|
||||
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
|
||||
{/* Stats by Tag - Only show if there's data and no tag filter */}
|
||||
{!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-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:tag" class="w-6 h-6" />
|
||||
By Category
|
||||
By Tag
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Tag</th>
|
||||
<th>Total Time</th>
|
||||
<th>Entries</th>
|
||||
<th>% of Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statsByCategory.filter(s => s.totalTime > 0).map(stat => (
|
||||
{statsByTag.filter(s => s.totalTime > 0).map(stat => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
{stat.category.color && (
|
||||
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.category.color}`}></span>
|
||||
{stat.tag.color && (
|
||||
<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>
|
||||
</td>
|
||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||
@@ -774,7 +811,7 @@ function getTimeRangeLabel(range: string) {
|
||||
<th>Date</th>
|
||||
<th>Member</th>
|
||||
<th>Client</th>
|
||||
<th>Category</th>
|
||||
<th>Tags</th>
|
||||
<th>Description</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
@@ -791,11 +828,16 @@ function getTimeRangeLabel(range: string) {
|
||||
<td>{e.user.name}</td>
|
||||
<td>{e.client.name}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
{e.category.color && (
|
||||
<span class="w-3 h-3 rounded-full" style={`background-color: ${e.category.color}`}></span>
|
||||
)}
|
||||
<span>{e.category.name}</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{e.tags.map(tag => (
|
||||
<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>{tag.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{e.tags.length === 0 && <span class="opacity-50">-</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td>{e.entry.description || '-'}</td>
|
||||
|
||||
Reference in New Issue
Block a user