This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../../db';
|
||||
import { clients, timeEntries, members, tags, timeEntryTags, users } from '../../../../db/schema';
|
||||
import { eq, and, desc, sql, inArray } from 'drizzle-orm';
|
||||
import { clients, timeEntries, members, tags, users } from '../../../../db/schema';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { formatTimeRange } from '../../../../lib/formatTime';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
@@ -38,44 +38,19 @@ const client = await db.select()
|
||||
if (!client) return Astro.redirect('/dashboard/clients');
|
||||
|
||||
// Get recent activity
|
||||
const recentEntriesData = await db.select({
|
||||
const recentEntries = await db.select({
|
||||
entry: timeEntries,
|
||||
user: users,
|
||||
tag: tags,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.leftJoin(users, eq(timeEntries.userId, users.id))
|
||||
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||
.where(eq(timeEntries.clientId, client.id))
|
||||
.orderBy(desc(timeEntries.startTime))
|
||||
.limit(10)
|
||||
.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
|
||||
const totalTimeResult = await db.select({
|
||||
totalDuration: sql<number>`sum(CASE WHEN ${timeEntries.endTime} IS NOT NULL THEN ${timeEntries.endTime} - ${timeEntries.startTime} ELSE 0 END)`
|
||||
@@ -206,27 +181,25 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Tags</th>
|
||||
<th>Tag</th>
|
||||
<th>User</th>
|
||||
<th>Date</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentEntries.map(({ entry, tags, user: entryUser }) => (
|
||||
{recentEntries.map(({ entry, tag, user: entryUser }) => (
|
||||
<tr>
|
||||
<td>{entry.description || '-'}</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{tags.length > 0 ? 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>
|
||||
)) : '-'}
|
||||
</div>
|
||||
{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>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>{entryUser?.name || 'Unknown'}</td>
|
||||
<td>{entry.startTime.toLocaleDateString()}</td>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../db';
|
||||
import { organizations, members, timeEntries, clients, tags, timeEntryTags } from '../../db/schema';
|
||||
import { eq, desc, and, isNull, gte, sql, inArray } from 'drizzle-orm';
|
||||
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
|
||||
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
||||
import { formatDuration } from '../../lib/formatTime';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
@@ -84,42 +84,18 @@ if (currentOrg) {
|
||||
|
||||
stats.totalClients = clientCount?.count || 0;
|
||||
|
||||
const recentEntriesData = await db.select({
|
||||
stats.recentEntries = await db.select({
|
||||
entry: timeEntries,
|
||||
client: clients,
|
||||
tag: tags,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
|
||||
.orderBy(desc(timeEntries.startTime))
|
||||
.limit(5)
|
||||
.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;
|
||||
@@ -229,14 +205,14 @@ const hasMembership = userOrgs.length > 0;
|
||||
</h2>
|
||||
{stats.recentEntries.length > 0 ? (
|
||||
<ul class="space-y-3 mt-4">
|
||||
{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: ${tags[0]?.color || '#3b82f6'}`}>
|
||||
{stats.recentEntries.map(({ entry, client, tag }) => (
|
||||
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${tag?.color || '#3b82f6'}`}>
|
||||
<div class="font-semibold text-sm">{client.name}</div>
|
||||
<div class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-2 items-center">
|
||||
<span class="flex gap-1 flex-wrap">
|
||||
{tags.length > 0 ? tags.map((tag: any) => (
|
||||
{tag ? (
|
||||
<span class="badge badge-xs badge-outline">{tag.name}</span>
|
||||
)) : <span class="italic opacity-50">No tags</span>}
|
||||
) : <span class="italic opacity-50">No tag</span>}
|
||||
</span>
|
||||
<span>• {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,8 @@ 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, tags, timeEntryTags, invoices } from '../../db/schema';
|
||||
import { eq, and, gte, lte, sql, desc, inArray, exists } from 'drizzle-orm';
|
||||
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
|
||||
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
@@ -103,64 +103,27 @@ if (selectedMemberId) {
|
||||
}
|
||||
|
||||
if (selectedTagId) {
|
||||
conditions.push(exists(
|
||||
db.select()
|
||||
.from(timeEntryTags)
|
||||
.where(and(
|
||||
eq(timeEntryTags.timeEntryId, timeEntries.id),
|
||||
eq(timeEntryTags.tagId, selectedTagId)
|
||||
))
|
||||
));
|
||||
conditions.push(eq(timeEntries.tagId, selectedTagId));
|
||||
}
|
||||
|
||||
if (selectedClientId) {
|
||||
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
||||
}
|
||||
|
||||
const entriesData = await db.select({
|
||||
const entries = await db.select({
|
||||
entry: timeEntries,
|
||||
user: users,
|
||||
client: clients,
|
||||
tag: tags,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.innerJoin(users, eq(timeEntries.userId, users.id))
|
||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.leftJoin(tags, eq(timeEntries.tagId, tags.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) => {
|
||||
@@ -178,7 +141,7 @@ const statsByMember = teamMembers.map(member => {
|
||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
const statsByTag = allTags.map(tag => {
|
||||
const tagEntries = entries.filter(e => e.tags.some(t => t.id === tag.id));
|
||||
const tagEntries = entries.filter(e => e.tag?.id === tag.id);
|
||||
const totalTime = tagEntries.reduce((sum, e) => {
|
||||
if (e.entry.endTime) {
|
||||
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
||||
@@ -811,7 +774,7 @@ function getTimeRangeLabel(range: string) {
|
||||
<th>Date</th>
|
||||
<th>Member</th>
|
||||
<th>Client</th>
|
||||
<th>Tags</th>
|
||||
<th>Tag</th>
|
||||
<th>Description</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
@@ -828,17 +791,16 @@ function getTimeRangeLabel(range: string) {
|
||||
<td>{e.user.name}</td>
|
||||
<td>{e.client.name}</td>
|
||||
<td>
|
||||
<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>
|
||||
{e.tag ? (
|
||||
<div class="badge badge-sm badge-outline flex items-center gap-1">
|
||||
{e.tag.color && (
|
||||
<span class="w-2 h-2 rounded-full" style={`background-color: ${e.tag.color}`}></span>
|
||||
)}
|
||||
<span>{e.tag.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span class="opacity-50">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{e.entry.description || '-'}</td>
|
||||
<td class="font-mono">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Icon } from 'astro-icon/components';
|
||||
import Timer from '../../components/Timer.vue';
|
||||
import ManualEntry from '../../components/ManualEntry.vue';
|
||||
import { db } from '../../db';
|
||||
import { timeEntries, clients, members, tags, timeEntryTags, users } from '../../db/schema';
|
||||
import { timeEntries, clients, members, tags, users } from '../../db/schema';
|
||||
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
||||
import { formatTimeRange } from '../../lib/formatTime';
|
||||
|
||||
@@ -99,10 +99,12 @@ const entries = await db.select({
|
||||
entry: timeEntries,
|
||||
client: clients,
|
||||
user: users,
|
||||
tag: tags,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.leftJoin(users, eq(timeEntries.userId, users.id))
|
||||
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderBy)
|
||||
.limit(pageSize)
|
||||
@@ -112,9 +114,11 @@ const entries = await db.select({
|
||||
const runningEntry = await db.select({
|
||||
entry: timeEntries,
|
||||
client: clients,
|
||||
tag: tags,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||
.where(and(
|
||||
eq(timeEntries.userId, user.id),
|
||||
sql`${timeEntries.endTime} IS NULL`
|
||||
@@ -165,6 +169,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
startTime: runningEntry.entry.startTime.getTime(),
|
||||
description: runningEntry.entry.description,
|
||||
clientId: runningEntry.entry.clientId,
|
||||
tagId: runningEntry.tag?.id,
|
||||
} : null}
|
||||
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
|
||||
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
|
||||
|
||||
Reference in New Issue
Block a user